From 8255eacfdc5d556399145dea021262653242a88a Mon Sep 17 00:00:00 2001 From: let-sunny Date: Sun, 29 Mar 2026 11:42:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20non-standard-naming=20rule=20?= =?UTF-8?q?=E2=80=94=20detect=20dev-unfriendly=20state=20names=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags variant state names that aren't in the web+mobile platform standard set (hover, active, pressed, selected, highlighted, disabled, focus, focused). Suggests the closest standard name (e.g., "Clicked" → "pressed", "Inactive" → "disabled"). This ensures missing-interaction-state can properly detect state variants after the designer fixes the names. Standard names defined in node-semantics.ts (STANDARD_STATE_NAMES) shared with interaction rules. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/REFERENCE.md | 3 +- src/core/contracts/rule.ts | 1 + src/core/engine/scoring.test.ts | 2 +- src/core/rules/naming/index.ts | 59 +++++++++++- .../rules/naming/non-standard-naming.test.ts | 94 +++++++++++++++++++ src/core/rules/node-semantics.ts | 38 ++++++++ src/core/rules/rule-config.ts | 6 ++ src/core/rules/rule-messages.ts | 9 ++ 8 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 src/core/rules/naming/non-standard-naming.test.ts diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index bda29787..f8bbc089 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -129,10 +129,11 @@ Override score, severity, or enable/disable individual rules: | `missing-interaction-state` | -3 | missing-info | | `missing-prototype` | -3 | missing-info | -**Minor (3 rules)** — naming issues with negligible impact (ΔV < 2%) +**Minor (4 rules)** — naming issues with negligible impact (ΔV < 2%) | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| +| `non-standard-naming` | -1 | suggestion | | `default-name` | -1 | suggestion | | `non-semantic-name` | -1 | suggestion | | `inconsistent-naming-convention` | -1 | suggestion | diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index 96fec5a3..c9bd4753 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -110,6 +110,7 @@ export type RuleId = | "missing-interaction-state" | "missing-prototype" // Minor — naming issues with negligible impact (ΔV < 2%) + | "non-standard-naming" | "default-name" | "non-semantic-name" | "inconsistent-naming-convention"; diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index e987e2e0..19cfa425 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -304,7 +304,7 @@ describe("calculateGrade (via calculateScores)", () => { "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"], + "minor": ["non-standard-naming", "default-name", "non-semantic-name", "inconsistent-naming-convention"], }; for (const cat of categories) { diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index 244bbe17..789030ad 100644 --- a/src/core/rules/naming/index.ts +++ b/src/core/rules/naming/index.ts @@ -1,7 +1,7 @@ import type { RuleCheckFn, RuleDefinition } from "../../contracts/rule.js"; import { defineRule } from "../rule-registry.js"; -import { defaultNameMsg, getDefaultNameSubType, nonSemanticNameMsg, inconsistentNamingMsg } from "../rule-messages.js"; -import { isExcludedName, isDefaultName, isNonSemanticName } from "../node-semantics.js"; +import { defaultNameMsg, getDefaultNameSubType, nonSemanticNameMsg, inconsistentNamingMsg, nonStandardNamingMsg } from "../rule-messages.js"; +import { isExcludedName, isDefaultName, isNonSemanticName, STANDARD_STATE_NAMES, STATE_NAME_SUGGESTIONS, STATE_LIKE_PATTERN } from "../node-semantics.js"; function detectNamingConvention(name: string): string | null { if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case"; @@ -141,3 +141,58 @@ export const inconsistentNamingConvention = defineRule({ check: inconsistentNamingConventionCheck, }); +// ============================================ +// non-standard-naming +// ============================================ + +const nonStandardNamingDef: RuleDefinition = { + id: "non-standard-naming", + name: "Non-Standard Naming", + category: "minor", + why: "Non-standard state names prevent interaction rules from detecting state variants — AI cannot generate correct :hover/:active/:disabled styles", + impact: "Interaction state detection fails, resulting in static UI with no state transitions", + fix: "Use platform-standard state names: hover, active, pressed, selected, highlighted, disabled, focus, focused", +}; + +const nonStandardNamingCheck: RuleCheckFn = (node, context) => { + // Only check COMPONENT_SET (variant container) + if (node.type !== "COMPONENT_SET") return null; + if (!node.componentPropertyDefinitions) return null; + + 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; + + for (const opt of options) { + if (typeof opt !== "string") continue; + const lower = opt.toLowerCase().trim(); + + // Skip if it's a standard name + if (STANDARD_STATE_NAMES.has(lower)) continue; + + // Check if it matches a known non-standard state name + if (STATE_LIKE_PATTERN.test(opt)) { + const suggestion = STATE_NAME_SUGGESTIONS[lower]; + if (suggestion) { + return { + ruleId: nonStandardNamingDef.id, + subType: "state-name" as const, + nodeId: node.id, + nodePath: context.path.join(" > "), + message: nonStandardNamingMsg.stateName(node.name, opt, suggestion), + }; + } + } + } + } + + return null; +}; + +export const nonStandardNaming = defineRule({ + definition: nonStandardNamingDef, + check: nonStandardNamingCheck, +}); + diff --git a/src/core/rules/naming/non-standard-naming.test.ts b/src/core/rules/naming/non-standard-naming.test.ts new file mode 100644 index 00000000..20749447 --- /dev/null +++ b/src/core/rules/naming/non-standard-naming.test.ts @@ -0,0 +1,94 @@ +import { makeNode, makeFile, makeContext } from "../test-helpers.js"; +import { nonStandardNaming } from "./index.js"; + +describe("non-standard-naming", () => { + it("has correct rule definition metadata", () => { + const def = nonStandardNaming.definition; + expect(def.id).toBe("non-standard-naming"); + expect(def.category).toBe("minor"); + }); + + it("flags non-standard state name 'Clicked'", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Clicked", "Disabled"] }, + }, + }); + const ctx = makeContext(); + const result = nonStandardNaming.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.subType).toBe("state-name"); + expect(result!.message).toContain("Clicked"); + expect(result!.message).toContain("pressed"); + }); + + it("flags 'Inactive' → suggests 'disabled'", () => { + const node = makeNode({ + id: "1:1", + name: "Toggle", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["On", "Inactive"] }, + }, + }); + const ctx = makeContext(); + const result = nonStandardNaming.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.message).toContain("On"); + expect(result!.message).toContain("active"); + }); + + it("passes when all state names are standard", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Hover", "Pressed", "Disabled"] }, + }, + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); + + it("passes for non-state variant properties", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "Size": { type: "VARIANT", variantOptions: ["Small", "Medium", "Large"] }, + }, + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); + + it("skips non-COMPONENT_SET nodes", () => { + const node = makeNode({ + id: "1:1", + name: "Button", + type: "INSTANCE", + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); + + it("accepts platform-standard names (pressed, selected, highlighted, focused)", () => { + const node = makeNode({ + id: "1:1", + name: "Nav Item", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Pressed", "Selected", "Highlighted", "Focused"] }, + }, + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); +}); diff --git a/src/core/rules/node-semantics.ts b/src/core/rules/node-semantics.ts index f8daad83..420eef3d 100644 --- a/src/core/rules/node-semantics.ts +++ b/src/core/rules/node-semantics.ts @@ -103,6 +103,44 @@ export function isStatefulComponent(node: AnalysisNode): boolean { return getStatefulComponentType(node) !== null; } +/** + * Standard state names accepted across web + mobile platforms. + * Used by missing-interaction-state (to detect presence) and + * non-standard-naming (to flag non-standard names). + */ +export const STANDARD_STATE_NAMES = new Set([ + // CSS pseudo-classes (web) + "default", "hover", "active", "focus", "focused", "disabled", + // Material Design (Android) + "pressed", + // UIKit (iOS) + "highlighted", + // Common + "selected", +]); + +/** + * Patterns that look like state names but aren't in the standard set. + * Maps common non-standard names to their standard equivalent for suggestions. + */ +export const STATE_NAME_SUGGESTIONS: Record = { + on: "active", + off: "disabled", + clicked: "pressed", + tapped: "pressed", + inactive: "disabled", + normal: "default", + rest: "default", + enabled: "default", + hovered: "hover", + activated: "active", + checked: "selected", + unchecked: "default", +}; + +/** Pattern to detect state-like variant option names (broad match) */ +export const STATE_LIKE_PATTERN = /\b(on|off|clicked|tapped|inactive|normal|rest|enabled|hovered|activated|checked|unchecked)\b/i; + // ── Overlay / Carousel patterns ────────────────────────────────────────────── /** Elements that open on top of current view */ diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index 950d502f..fdccad0b 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -30,6 +30,7 @@ export const RULE_ID_CATEGORY: Record = { "missing-interaction-state": "interaction", "missing-prototype": "interaction", // Minor + "non-standard-naming": "minor", "default-name": "minor", "non-semantic-name": "minor", "inconsistent-naming-convention": "minor", @@ -135,6 +136,11 @@ export const RULE_CONFIGS: Record = { }, // ── Minor ── + "non-standard-naming": { + severity: "suggestion", + score: -1, + enabled: true, + }, "default-name": { severity: "suggestion", score: -1, diff --git a/src/core/rules/rule-messages.ts b/src/core/rules/rule-messages.ts index 29970772..97e8cc4b 100644 --- a/src/core/rules/rule-messages.ts +++ b/src/core/rules/rule-messages.ts @@ -111,6 +111,15 @@ export const detachedInstanceMsg = (name: string, componentName: string) => export const variantStructureMismatchMsg = (name: string, mismatchCount: number, totalVariants: number) => `"${name}" has ${mismatchCount}/${totalVariants} variants with different child structures — unify variant structures using visibility toggles for optional elements`; +// ── non-standard-naming ────────────────────────────────────────────────────── + +export type NonStandardNamingSubType = "state-name"; + +export const nonStandardNamingMsg = { + stateName: (name: string, nonStandard: string, suggestion: string) => + `"${name}" has non-standard state name "${nonStandard}" — use "${suggestion}" for dev-friendly naming`, +}; + // ── default-name ───────────────────────────────────────────────────────────── export type DefaultNameSubType = "frame" | "group" | "vector" | "shape" | "text" | "image" | "component" | "instance"; From 63b58bd1ffe827e2148a42e0de36ab5948ea3ffe Mon Sep 17 00:00:00 2001 From: let-sunny Date: Sun, 29 Mar 2026 11:45:35 +0900 Subject: [PATCH 2/4] fix: add enabled/dragged to standard state names, add source references Research verified against official specs: - CSS: :hover, :active, :focus, :disabled, :enabled (MDN) - Material Design 3: pressed, dragged, hovered, focused (m3.material.io) - UIKit: highlighted, selected, disabled, focused (Apple Developer) Added "enabled" and "dragged" to STANDARD_STATE_NAMES. Removed "enabled" from non-standard suggestions (it's a CSS standard). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/rules/node-semantics.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/core/rules/node-semantics.ts b/src/core/rules/node-semantics.ts index 420eef3d..f90082f6 100644 --- a/src/core/rules/node-semantics.ts +++ b/src/core/rules/node-semantics.ts @@ -108,11 +108,19 @@ export function isStatefulComponent(node: AnalysisNode): boolean { * Used by missing-interaction-state (to detect presence) and * non-standard-naming (to flag non-standard names). */ +/** + * Standard state names across web + mobile platforms. + * + * Sources: + * - CSS: :hover, :active, :focus, :disabled, :enabled — https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-classes + * - Material Design 3: Hovered, Focused, Pressed, Dragged — https://m3.material.io/foundations/interaction/states + * - UIKit: highlighted, selected, disabled, focused — https://developer.apple.com/documentation/uikit/uicontrol/state + */ export const STANDARD_STATE_NAMES = new Set([ // CSS pseudo-classes (web) - "default", "hover", "active", "focus", "focused", "disabled", - // Material Design (Android) - "pressed", + "default", "hover", "active", "focus", "focused", "disabled", "enabled", + // Material Design 3 (Android) + "pressed", "dragged", // UIKit (iOS) "highlighted", // Common @@ -131,7 +139,6 @@ export const STATE_NAME_SUGGESTIONS: Record = { inactive: "disabled", normal: "default", rest: "default", - enabled: "default", hovered: "hover", activated: "active", checked: "selected", @@ -139,7 +146,7 @@ export const STATE_NAME_SUGGESTIONS: Record = { }; /** Pattern to detect state-like variant option names (broad match) */ -export const STATE_LIKE_PATTERN = /\b(on|off|clicked|tapped|inactive|normal|rest|enabled|hovered|activated|checked|unchecked)\b/i; +export const STATE_LIKE_PATTERN = /\b(on|off|clicked|tapped|inactive|normal|rest|hovered|activated|checked|unchecked)\b/i; // ── Overlay / Carousel patterns ────────────────────────────────────────────── From 526fe17c7085f665284e3b2718c20909bea85705 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Sun, 29 Mar 2026 11:56:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20off=E2=86=92default,=20fix=20text,=20test=20coverag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - off → default (not disabled — off is toggle's base state) - Fix text updated with full standard name list including enabled/dragged - Separate test for Inactive → disabled (was hidden behind On) - Added enabled/dragged acceptance test Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/rules/naming/index.ts | 2 +- .../rules/naming/non-standard-naming.test.ts | 34 +++++++++++++++++-- src/core/rules/node-semantics.ts | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/core/rules/naming/index.ts b/src/core/rules/naming/index.ts index 789030ad..14514891 100644 --- a/src/core/rules/naming/index.ts +++ b/src/core/rules/naming/index.ts @@ -151,7 +151,7 @@ const nonStandardNamingDef: RuleDefinition = { category: "minor", why: "Non-standard state names prevent interaction rules from detecting state variants — AI cannot generate correct :hover/:active/:disabled styles", impact: "Interaction state detection fails, resulting in static UI with no state transitions", - fix: "Use platform-standard state names: hover, active, pressed, selected, highlighted, disabled, focus, focused", + fix: "Use platform-standard state names: default, hover, active, pressed, selected, highlighted, disabled, enabled, focus, focused, dragged", }; const nonStandardNamingCheck: RuleCheckFn = (node, context) => { diff --git a/src/core/rules/naming/non-standard-naming.test.ts b/src/core/rules/naming/non-standard-naming.test.ts index 20749447..42274e2f 100644 --- a/src/core/rules/naming/non-standard-naming.test.ts +++ b/src/core/rules/naming/non-standard-naming.test.ts @@ -26,13 +26,13 @@ describe("non-standard-naming", () => { expect(result!.message).toContain("pressed"); }); - it("flags 'Inactive' → suggests 'disabled'", () => { + it("flags 'On' → suggests 'active'", () => { const node = makeNode({ id: "1:1", name: "Toggle", type: "COMPONENT_SET", componentPropertyDefinitions: { - "State": { type: "VARIANT", variantOptions: ["On", "Inactive"] }, + "State": { type: "VARIANT", variantOptions: ["Default", "On"] }, }, }); const ctx = makeContext(); @@ -43,6 +43,23 @@ describe("non-standard-naming", () => { expect(result!.message).toContain("active"); }); + it("flags 'Inactive' → suggests 'disabled'", () => { + const node = makeNode({ + id: "1:1", + name: "Toggle", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Default", "Inactive"] }, + }, + }); + const ctx = makeContext(); + const result = nonStandardNaming.check(node, ctx); + + expect(result).not.toBeNull(); + expect(result!.message).toContain("Inactive"); + expect(result!.message).toContain("disabled"); + }); + it("passes when all state names are standard", () => { const node = makeNode({ id: "1:1", @@ -91,4 +108,17 @@ describe("non-standard-naming", () => { const ctx = makeContext(); expect(nonStandardNaming.check(node, ctx)).toBeNull(); }); + + it("accepts enabled and dragged (CSS/Material standard)", () => { + const node = makeNode({ + id: "1:1", + name: "Chip", + type: "COMPONENT_SET", + componentPropertyDefinitions: { + "State": { type: "VARIANT", variantOptions: ["Enabled", "Dragged", "Disabled"] }, + }, + }); + const ctx = makeContext(); + expect(nonStandardNaming.check(node, ctx)).toBeNull(); + }); }); diff --git a/src/core/rules/node-semantics.ts b/src/core/rules/node-semantics.ts index f90082f6..39daeec5 100644 --- a/src/core/rules/node-semantics.ts +++ b/src/core/rules/node-semantics.ts @@ -133,7 +133,7 @@ export const STANDARD_STATE_NAMES = new Set([ */ export const STATE_NAME_SUGGESTIONS: Record = { on: "active", - off: "disabled", + off: "default", clicked: "pressed", tapped: "pressed", inactive: "disabled", From 8b65d113ed8ec5a8d1148f1d8fadead4a2febcd7 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Sun, 29 Mar 2026 12:02:21 +0900 Subject: [PATCH 4/4] fix: merge duplicate JSDoc for STANDARD_STATE_NAMES Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/rules/node-semantics.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/rules/node-semantics.ts b/src/core/rules/node-semantics.ts index 39daeec5..f3b92cb0 100644 --- a/src/core/rules/node-semantics.ts +++ b/src/core/rules/node-semantics.ts @@ -104,12 +104,9 @@ export function isStatefulComponent(node: AnalysisNode): boolean { } /** - * Standard state names accepted across web + mobile platforms. + * Standard state names across web + mobile platforms. * Used by missing-interaction-state (to detect presence) and * non-standard-naming (to flag non-standard names). - */ -/** - * Standard state names across web + mobile platforms. * * Sources: * - CSS: :hover, :active, :focus, :disabled, :enabled — https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-classes