diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4b130411..bda29787 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -122,6 +122,13 @@ Override score, severity, or enable/disable individual rules: | `raw-value` | -3 | missing-info | | `irregular-spacing` | -2 | missing-info | +**Interaction (2 rules)** — missing state variants and prototypes for interactive components + +| Rule ID | Default Score | Default Severity | +|---------|--------------|-----------------| +| `missing-interaction-state` | -3 | missing-info | +| `missing-prototype` | -3 | missing-info | + **Minor (3 rules)** — naming issues with negligible impact (ΔV < 2%) | Rule ID | Default Score | Default Severity | diff --git a/src/core/contracts/category.ts b/src/core/contracts/category.ts index 5e9bf024..2bdfcbb8 100644 --- a/src/core/contracts/category.ts +++ b/src/core/contracts/category.ts @@ -5,6 +5,7 @@ export const CategorySchema = z.enum([ "responsive-critical", "code-quality", "token-management", + "interaction", "minor", ]); @@ -17,5 +18,6 @@ export const CATEGORY_LABELS: Record = { "responsive-critical": "Responsive Critical", "code-quality": "Code Quality", "token-management": "Token Management", + "interaction": "Interaction", "minor": "Minor", }; diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index ab706e45..96fec5a3 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -106,6 +106,9 @@ export type RuleId = // Token Management — raw values without design tokens | "raw-value" | "irregular-spacing" + // Interaction — missing state variants and prototype links for interactive components + | "missing-interaction-state" + | "missing-prototype" // Minor — naming issues with negligible impact (ΔV < 2%) | "default-name" | "non-semantic-name" diff --git a/src/core/engine/integration.test.ts b/src/core/engine/integration.test.ts index f047aff3..79574085 100644 --- a/src/core/engine/integration.test.ts +++ b/src/core/engine/integration.test.ts @@ -71,10 +71,10 @@ describe("Integration: fixture → analyze → score", () => { expect(scores.overall.score).toBe(scores.overall.percentage); expect(scores.overall.maxScore).toBe(100); - // Exactly 5 categories present + // Exactly 6 categories present const categories = Object.keys(scores.byCategory).sort(); expect(categories).toEqual( - ["code-quality", "minor", "pixel-critical", "responsive-critical", "token-management"], + ["code-quality", "interaction", "minor", "pixel-critical", "responsive-critical", "token-management"], ); // Each category has valid percentages diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index 7eadf02f..e987e2e0 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -216,6 +216,7 @@ describe("calculateScores", () => { expect(scores.byCategory["token-management"].percentage).toBe(100); expect(scores.byCategory["code-quality"].percentage).toBe(100); + expect(scores.byCategory["interaction"].percentage).toBe(100); expect(scores.byCategory["minor"].percentage).toBe(100); expect(scores.byCategory["responsive-critical"].percentage).toBe(100); }); @@ -230,10 +231,11 @@ describe("calculateScores", () => { scores.byCategory["responsive-critical"].percentage, scores.byCategory["code-quality"].percentage, scores.byCategory["token-management"].percentage, + scores.byCategory["interaction"].percentage, scores.byCategory["minor"].percentage, ]; const expectedOverall = Math.round( - categoryPercentages.reduce((a, b) => a + b, 0) / 5 + categoryPercentages.reduce((a, b) => a + b, 0) / 6 ); expect(scores.overall.percentage).toBe(expectedOverall); }); @@ -295,12 +297,13 @@ describe("calculateGrade (via calculateScores)", () => { it("score < 50% -> F", () => { const issues: AnalysisIssue[] = []; - const categories: Category[] = ["pixel-critical", "responsive-critical", "code-quality", "token-management", "minor"]; + const categories: Category[] = ["pixel-critical", "responsive-critical", "code-quality", "token-management", "interaction", "minor"]; const rulesPerCat: Record = { "pixel-critical": ["no-auto-layout", "non-layout-container", "absolute-position-in-auto-layout"], "responsive-critical": ["fixed-size-in-auto-layout", "missing-size-constraint"], "code-quality": ["missing-component", "detached-instance", "variant-structure-mismatch", "deep-nesting"], "token-management": ["raw-value", "irregular-spacing"], + "interaction": ["missing-interaction-state", "missing-prototype"], "minor": ["default-name", "non-semantic-name", "inconsistent-naming-convention"], }; diff --git a/src/core/engine/scoring.ts b/src/core/engine/scoring.ts index 86ef5c3f..c8a5dbed 100644 --- a/src/core/engine/scoring.ts +++ b/src/core/engine/scoring.ts @@ -96,6 +96,7 @@ const CATEGORY_WEIGHT: Record = { "responsive-critical": 1.0, "code-quality": 1.0, "token-management": 1.0, + "interaction": 1.0, "minor": 1.0, }; diff --git a/src/core/rules/index.ts b/src/core/rules/index.ts index 25bc1f8a..c0109296 100644 --- a/src/core/rules/index.ts +++ b/src/core/rules/index.ts @@ -9,3 +9,4 @@ export * from "./structure/index.js"; export * from "./token/index.js"; export * from "./component/index.js"; export * from "./naming/index.js"; +export * from "./interaction/index.js"; diff --git a/src/core/rules/interaction/index.test.ts b/src/core/rules/interaction/index.test.ts new file mode 100644 index 00000000..58353a8b --- /dev/null +++ b/src/core/rules/interaction/index.test.ts @@ -0,0 +1,254 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { missingInteractionState, missingPrototype } from "./index.js"; + +// ============================================ +// missing-interaction-state +// ============================================ + +describe("missing-interaction-state", () => { + it("has correct rule definition metadata", () => { + const def = missingInteractionState.definition; + expect(def.id).toBe("missing-interaction-state"); + expect(def.category).toBe("interaction"); + }); + + it("flags INSTANCE button without hover variant", () => { + const node = makeNode({ id: "1:1", name: "Primary Button", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext({ path: ["Page", "Button"] }); + const result = missingInteractionState.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("hover"); + expect(result!.message).toContain("Hover"); + }); + + it("skips non-interactive names", () => { + const node = makeNode({ id: "1:1", name: "Card", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext(); + expect(missingInteractionState.check(node, ctx)).toBeNull(); + }); + + it("skips FRAME nodes (only INSTANCE/COMPONENT)", () => { + const node = makeNode({ id: "1:1", name: "Button", type: "FRAME" }); + const ctx = makeContext(); + expect(missingInteractionState.check(node, ctx)).toBeNull(); + }); + + it("passes when variant property has pressed option (active subType)", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "INSTANCE", + componentId: "c:1", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Hover", "Pressed", "Disabled"] }, + }, + }); + const ctx = makeContext(); + expect(missingInteractionState.check(node, ctx)).toBeNull(); + }); + + it("passes when variant property has hover option", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "INSTANCE", + componentId: "c:1", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Hover", "Pressed", "Disabled"] }, + }, + }); + const ctx = makeContext(); + expect(missingInteractionState.check(node, ctx)).toBeNull(); + }); + + it("passes when component master has hover variant", () => { + const masterNode = makeNode({ + id: "c:1", + name: "Button Master", + type: "COMPONENT", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Hover", "Pressed", "Disabled"] }, + }, + }); + const node = makeNode({ + id: "1:1", + name: "Button", + type: "INSTANCE", + componentId: "c:1", + }); + const file = makeFile({ componentDefinitions: { "c:1": masterNode } }); + const ctx = makeContext({ file }); + expect(missingInteractionState.check(node, ctx)).toBeNull(); + }); + + it("still flags hover even when ON_HOVER prototype exists (prototype ≠ variant)", () => { + const node = makeNode({ + id: "1:1", + name: "Link Item", + type: "INSTANCE", + componentId: "c:1", + interactions: [ + { trigger: { type: "ON_HOVER" }, actions: [{ navigation: "CHANGE_TO", destinationId: "d:1" }] }, + ], + }); + const ctx = makeContext(); + const result = missingInteractionState.check(node, ctx); + expect(result).not.toBeNull(); + expect(result!.subType).toBe("hover"); + }); + + it("flags input without focus variant", () => { + const node = makeNode({ id: "1:1", name: "Search Input", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext({ path: ["Page", "Input"] }); + const result = missingInteractionState.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("focus"); + }); + + it("flags tab without hover variant", () => { + const node = makeNode({ id: "1:1", name: "Navigation Tab", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext({ path: ["Page", "Tab"] }); + const result = missingInteractionState.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("hover"); + }); + + it("deduplicates per componentId + subType", () => { + const ctx = makeContext({ path: ["Page", "Section"] }); + // Use link — only expects hover (single state), so dedup is clean + const node1 = makeNode({ id: "1:1", name: "Link", type: "INSTANCE", componentId: "c:1" }); + const node2 = makeNode({ id: "1:2", name: "Link", type: "INSTANCE", componentId: "c:1" }); + + const result1 = missingInteractionState.check(node1, ctx); + const result2 = missingInteractionState.check(node2, ctx); + + expect(result1).not.toBeNull(); + expect(result1!.subType).toBe("hover"); + expect(result2).toBeNull(); // deduped: same componentId + hover + }); +}); + +// ============================================ +// missing-prototype +// ============================================ + +describe("missing-prototype", () => { + it("has correct rule definition metadata", () => { + const def = missingPrototype.definition; + expect(def.id).toBe("missing-prototype"); + expect(def.category).toBe("interaction"); + }); + + it("flags button without ON_CLICK", () => { + const node = makeNode({ id: "1:1", name: "CTA Button", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext({ path: ["Page", "Button"] }); + const result = missingPrototype.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("button"); + }); + + it("flags link without ON_CLICK", () => { + const node = makeNode({ id: "1:1", name: "Footer Link", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext({ path: ["Page", "Link"] }); + const result = missingPrototype.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("navigation"); + }); + + it("flags dropdown without ON_CLICK (overlay subType)", () => { + const node = makeNode({ id: "1:1", name: "Country Dropdown", type: "FRAME" }); + const ctx = makeContext({ path: ["Page", "Dropdown"] }); + const result = missingPrototype.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("overlay"); + }); + + it("flags drawer without ON_CLICK (overlay subType)", () => { + const node = makeNode({ id: "1:1", name: "Side Drawer", type: "FRAME" }); + const ctx = makeContext({ path: ["Page", "Drawer"] }); + const result = missingPrototype.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("overlay"); + }); + + it("passes when ON_CLICK interaction exists", () => { + const node = makeNode({ + id: "1:1", + name: "Nav Link", + type: "INSTANCE", + componentId: "c:1", + interactions: [ + { trigger: { type: "ON_CLICK" }, actions: [{ navigation: "NAVIGATE", destinationId: "page:2" }] }, + ], + }); + const ctx = makeContext(); + expect(missingPrototype.check(node, ctx)).toBeNull(); + }); + + it("passes when component master has ON_CLICK (instance inheritance)", () => { + const masterNode = makeNode({ + id: "c:1", + name: "Button Master", + type: "COMPONENT", + interactions: [ + { trigger: { type: "ON_CLICK" }, actions: [{ navigation: "NAVIGATE", destinationId: "page:2" }] }, + ], + }); + const node = makeNode({ id: "1:1", name: "CTA Button", type: "INSTANCE", componentId: "c:1" }); + const file = makeFile({ componentDefinitions: { "c:1": masterNode } }); + const ctx = makeContext({ file, path: ["Page", "Button"] }); + expect(missingPrototype.check(node, ctx)).toBeNull(); + }); + + it("skips non-interactive names", () => { + const node = makeNode({ id: "1:1", name: "Product Card", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext(); + expect(missingPrototype.check(node, ctx)).toBeNull(); + }); + + it("flags input without ON_CLICK", () => { + const node = makeNode({ id: "1:1", name: "Email Input", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext({ path: ["Page", "Input"] }); + const result = missingPrototype.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("input"); + }); + + it("flags toggle without ON_CLICK", () => { + const node = makeNode({ id: "1:1", name: "Dark Mode Toggle", type: "INSTANCE", componentId: "c:1" }); + const ctx = makeContext({ path: ["Page", "Toggle"] }); + const result = missingPrototype.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("toggle"); + }); + + it("deduplicates per componentId + subType", () => { + const ctx = makeContext({ path: ["Page", "Section"] }); + const node1 = makeNode({ id: "1:1", name: "Tab Item", type: "INSTANCE", componentId: "c:1" }); + const node2 = makeNode({ id: "1:2", name: "Tab Item", type: "INSTANCE", componentId: "c:1" }); + + const result1 = missingPrototype.check(node1, ctx); + const result2 = missingPrototype.check(node2, ctx); + + expect(result1).not.toBeNull(); + expect(result2).toBeNull(); // deduped + }); + + it("flags FRAME with interactive name (detached instance)", () => { + const node = makeNode({ id: "1:1", name: "Submit Button", type: "FRAME" }); + const ctx = makeContext({ path: ["Page", "Form"] }); + const result = missingPrototype.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("button"); + }); +}); diff --git a/src/core/rules/interaction/index.ts b/src/core/rules/interaction/index.ts new file mode 100644 index 00000000..89861e80 --- /dev/null +++ b/src/core/rules/interaction/index.ts @@ -0,0 +1,246 @@ +import type { RuleCheckFn, RuleDefinition, RuleContext } from "../../contracts/rule.js"; +import { getAnalysisState } from "../../contracts/rule.js"; +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; +} + +/** Expected state variants by interactive type */ +const EXPECTED_STATES: Record = { + button: ["hover", "active", "disabled"], + link: ["hover"], + tab: ["hover", "active"], + input: ["focus", "disabled"], + toggle: ["disabled"], +}; + +/** State name patterns — web + mobile platform standard names */ +const STATE_PATTERNS: Record = { + hover: /\bhover\b/i, + disabled: /\bdisabled\b/i, + active: /\b(active|pressed|selected|highlighted)\b/i, + focus: /\bfocus(ed)?\b/i, +}; + +// ============================================ +// Helpers +// ============================================ + +/** Dedup key: emit at most one violation per componentId + subType */ +const SEEN_KEY = "missing-interaction-state:seen"; + +function getSeen(context: RuleContext): Set { + return getAnalysisState(context, SEEN_KEY, () => new Set()); +} + +/** + * Check if a state variant exists via componentPropertyDefinitions. + * Looks for VARIANT type properties where variantOptions contain the state name. + */ +function hasStateInVariantProps(node: AnalysisNode, statePattern: RegExp): boolean { + if (!node.componentPropertyDefinitions) return false; + for (const prop of Object.values(node.componentPropertyDefinitions)) { + const p = prop as Record; + if (p["type"] !== "VARIANT") continue; + const options = p["variantOptions"]; + if (!Array.isArray(options)) continue; + if (options.some((opt) => typeof opt === "string" && statePattern.test(opt))) { + return true; + } + } + return false; +} + +/** + * Check if a state variant exists via component master's componentPropertyDefinitions. + * Falls back to componentDefinitions (fetched masters) when the instance itself + * doesn't carry the property definitions. + */ +function hasStateInComponentMaster( + node: AnalysisNode, + context: RuleContext, + statePattern: RegExp, +): boolean { + if (!node.componentId) return false; + const defs = context.file.componentDefinitions; + if (!defs) return false; + const master = defs[node.componentId]; + if (!master) return false; + return hasStateInVariantProps(master, statePattern); +} + +// ============================================ +// missing-interaction-state +// ============================================ + +const missingInteractionStateDef: RuleDefinition = { + id: "missing-interaction-state", + name: "Missing Interaction State", + category: "interaction", + why: "Interactive components without state variants force AI to guess hover/focus/disabled appearances — or skip them entirely", + impact: "Generated code has no :hover, :focus, or :disabled styles, making the UI feel static and unresponsive", + fix: "Add state variants (Hover, Disabled, Focus, Active) to interactive components in Figma", +}; + +const missingInteractionStateCheck: RuleCheckFn = (node, context) => { + // Only check component instances and components + if (node.type !== "INSTANCE" && node.type !== "COMPONENT") return null; + + const interactiveType = getInteractiveType(node); + if (!interactiveType) return null; + + const expectedStates = EXPECTED_STATES[interactiveType]; + if (!expectedStates) return null; + + const seen = getSeen(context); + const nodePath = context.path.join(" > "); + + for (const state of expectedStates) { + const dedupeKey = `${node.componentId ?? node.id}:${state}`; + if (seen.has(dedupeKey)) continue; + + const pattern = STATE_PATTERNS[state]; + + // Check variant properties on instance + if (hasStateInVariantProps(node, pattern)) continue; + + // Check variant properties on component master (fetched definitions) + if (hasStateInComponentMaster(node, context, pattern)) continue; + + // Missing state — report first missing one + seen.add(dedupeKey); + return { + ruleId: missingInteractionStateDef.id, + subType: state, + nodeId: node.id, + nodePath, + message: missingInteractionStateMsg[state](node.name), + }; + } + + return null; +}; + +export const missingInteractionState = defineRule({ + definition: missingInteractionStateDef, + check: missingInteractionStateCheck, +}); + +// ============================================ +// missing-prototype +// ============================================ + +/** Interactive types that need click prototype */ +const PROTOTYPE_TYPES: Record = { + 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); + if (interactiveType) { + const mapped = PROTOTYPE_TYPES[interactiveType]; + if (mapped) return mapped; + } + return null; +} + +function hasInteractionTrigger(node: AnalysisNode, triggerType: string): boolean { + if (!node.interactions || !Array.isArray(node.interactions)) return false; + return node.interactions.some((interaction) => { + const i = interaction as { trigger?: { type?: string } }; + return i.trigger?.type === triggerType; + }); +} + +/** 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; + // 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; + } + return false; +} + +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", +}; + +const SEEN_PROTO_KEY = "missing-prototype:seen"; + +function getSeenProto(context: RuleContext): Set { + return getAnalysisState(context, SEEN_PROTO_KEY, () => new Set()); +} + +const missingPrototypeCheck: RuleCheckFn = (node, context) => { + if (node.type !== "INSTANCE" && node.type !== "COMPONENT" && node.type !== "FRAME") return null; + + const subType = getPrototypeSubType(node); + if (!subType) return null; + + // Already has click interaction (check instance + master) + if (hasClickInteraction(node, context)) return null; + + // Dedup per componentId + subType + const seen = getSeenProto(context); + const dedupeKey = `${node.componentId ?? node.id}:${subType}`; + if (seen.has(dedupeKey)) return null; + seen.add(dedupeKey); + + return { + ruleId: missingPrototypeDef.id, + subType, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: missingPrototypeMsg[subType](node.name), + }; +}; + +export const missingPrototype = defineRule({ + definition: missingPrototypeDef, + check: missingPrototypeCheck, +}); diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index 306d016b..950d502f 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -26,6 +26,9 @@ export const RULE_ID_CATEGORY: Record = { // Token Management "raw-value": "token-management", "irregular-spacing": "token-management", + // Interaction + "missing-interaction-state": "interaction", + "missing-prototype": "interaction", // Minor "default-name": "minor", "non-semantic-name": "minor", @@ -119,6 +122,18 @@ export const RULE_CONFIGS: Record = { }, }, + // ── Interaction ── + "missing-interaction-state": { + severity: "missing-info", + score: -3, + enabled: true, + }, + "missing-prototype": { + severity: "missing-info", + score: -3, + enabled: true, + }, + // ── Minor ── "default-name": { severity: "suggestion", diff --git a/src/core/rules/rule-messages.ts b/src/core/rules/rule-messages.ts index bc5e7983..dae04d45 100644 --- a/src/core/rules/rule-messages.ts +++ b/src/core/rules/rule-messages.ts @@ -143,6 +143,42 @@ export const defaultNameMsg = (type: string, name: string) => export const nonSemanticNameMsg = (type: string, name: string) => `${type} "${name}" is a non-semantic name — rename to describe its role (e.g., "Divider", "Background")`; +// ── missing-interaction-state ───────────────────────────────────────────────── + +export type MissingInteractionStateSubType = "hover" | "disabled" | "active" | "focus"; + +export const missingInteractionStateMsg = { + hover: (name: string) => + `"${name}" looks interactive but has no Hover state variant — add a State=Hover variant`, + disabled: (name: string) => + `"${name}" looks interactive but has no Disabled state variant — add a State=Disabled variant`, + active: (name: string) => + `"${name}" looks interactive but has no Active state variant — add a State=Active variant`, + focus: (name: string) => + `"${name}" looks interactive but has no Focus state variant — add a State=Focus variant`, +}; + +// ── missing-prototype ───────────────────────────────────────────────────────── + +export type MissingPrototypeSubType = "button" | "navigation" | "tab" | "overlay" | "carousel" | "input" | "toggle"; + +export const missingPrototypeMsg = { + button: (name: string) => + `"${name}" looks like a button but has no click prototype — add an ON_CLICK interaction to define the click behavior`, + navigation: (name: string) => + `"${name}" looks like a navigation link but has no click prototype — add an ON_CLICK interaction to define the destination`, + tab: (name: string) => + `"${name}" looks like a tab but has no click prototype — add an ON_CLICK interaction to define tab switching behavior`, + 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`, + 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) => + `"${name}" looks like a toggle but has no click prototype — add an ON_CLICK interaction to define on/off behavior`, +}; + // ── inconsistent-naming-convention ─────────────────────────────────────────── export const inconsistentNamingMsg = (name: string, nodeConvention: string, dominantConvention: string) => diff --git a/src/core/ui-constants.ts b/src/core/ui-constants.ts index 3fa53796..59115400 100644 --- a/src/core/ui-constants.ts +++ b/src/core/ui-constants.ts @@ -20,6 +20,8 @@ export const CATEGORY_DESCRIPTIONS: Record = { "Component reuse, detached instances, variant structure, nesting depth", "token-management": "Design token binding for colors, fonts, shadows, opacity, spacing grid", + "interaction": + "State variants for interactive components — hover, disabled, active, focus", "minor": "Semantic layer names, naming conventions, default names", };