diff --git a/.changeset/agent-skills-enhancements.md b/.changeset/agent-skills-enhancements.md new file mode 100644 index 00000000..18ac76c7 --- /dev/null +++ b/.changeset/agent-skills-enhancements.md @@ -0,0 +1,5 @@ +--- +"@adobe/spectrum-design-data-mcp": patch +--- + +Improved Agent Skills with better folder naming (agent-skills/), comprehensive documentation including quick start examples, state management guide, accessibility checklist, and enhanced error messages with actionable next steps. diff --git a/.changeset/agent-skills-feature.md b/.changeset/agent-skills-feature.md new file mode 100644 index 00000000..de3ab975 --- /dev/null +++ b/.changeset/agent-skills-feature.md @@ -0,0 +1,8 @@ +--- +"@adobe/spectrum-design-data-mcp": minor +--- + +Added Agent Skills for component building and token discovery. +Agent Skills are markdown guides that help AI agents orchestrate +MCP tools into complete workflows. Includes Component Builder and +Token Finder skills, plus new workflow-oriented MCP tools. diff --git a/.cursor/mcp.json b/.cursor/mcp.json index e0427a2a..fcdf28d0 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,28 +1,10 @@ { "mcpServers": { - "changesets Docs": { - "url": "https://gitmcp.io/changesets/changesets" - }, - "ava Docs": { - "url": "https://gitmcp.io/avajs/ava" - }, "moon": { "command": "moon", "args": ["mcp"], "cwd": "${workspaceFolder}" }, - "handlebars.js Docs": { - "url": "https://gitmcp.io/handlebars-lang/handlebars.js" - }, - "pnpm Docs": { - "url": "https://gitmcp.io/pnpm/pnpm" - }, - "commitlint Docs": { - "url": "https://gitmcp.io/conventional-changelog/commitlint" - }, - "GitHub cli Docs": { - "url": "https://gitmcp.io/cli/cli" - }, "nixt Docs": { "url": "https://gitmcp.io/vesln/nixt" } diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 00000000..f325d9ef --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,77 @@ +name: Publish Packages (Reusable) + +on: + workflow_call: + inputs: + snapshot-tag: + description: "Optional snapshot tag for prerelease versions" + required: false + type: string + secrets: + GH_TOKEN: + required: true + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write # Required for npm trusted publishing (OIDC) + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get last author info + id: author + run: | + echo "authorName=$(git log -1 --pretty=format:'%an')" >> $GITHUB_OUTPUT + echo "authorEmail=$(git log -1 --pretty=format:'%ae')" >> $GITHUB_OUTPUT + + - uses: moonrepo/setup-toolchain@v0 + with: + auto-install: true + + - run: moon setup + - run: moon run :build --query "projectSource~packages/*" + + # Validate OIDC prerequisites before publishing + - name: Validate Publishing Prerequisites + uses: GarthDB/changesets-publish-validator@v1 + with: + auth-method: oidc + + # Install npm CLI with OIDC support for snapshot releases + - name: Install npm with OIDC support + if: inputs.snapshot-tag != '' + run: npm install -g npm@11.6.2 + + # Snapshot release + - name: Snapshot release + if: inputs.snapshot-tag != '' + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + SNAPSHOT_TAG: ${{ inputs.snapshot-tag }} + USERNAME: ${{ steps.author.outputs.authorName }} + EMAIL: ${{ steps.author.outputs.authorEmail }} + run: | + pnpm changeset version --snapshot $SNAPSHOT_TAG + git config --global user.name "$USERNAME" + git config --global user.email "$EMAIL" + git add . + git commit -m "chore: snapshot release $SNAPSHOT_TAG" + pnpm changeset publish --tag $SNAPSHOT_TAG + git push origin HEAD + git push --tags + + # Standard release + - name: Create Release Pull Request or Publish to npm + if: inputs.snapshot-tag == '' + uses: GarthDB/changesets-action@v1.6.8 + with: + commit: "chore: release" + publish: pnpm release + oidcAuth: true + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/release-snapshot.yml b/.github/workflows/release-snapshot.yml index 13c219ef..099ad86a 100644 --- a/.github/workflows/release-snapshot.yml +++ b/.github/workflows/release-snapshot.yml @@ -11,42 +11,24 @@ on: jobs: get-snapshot-tag: runs-on: ubuntu-latest - permissions: - contents: write - id-token: write + outputs: + snapshot-tag: ${{ steps.compute-tag.outputs.tag }} steps: - - name: Split branch name - id: split + - name: Compute snapshot tag + id: compute-tag env: BRANCH: ${{ github.ref_name }} - run: echo "fragment=${BRANCH##*snapshot-}" >> $GITHUB_OUTPUT - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Get last author info - id: author run: | - echo "authorName=$(git log -1 --pretty=format:'%an')" >> $GITHUB_OUTPUT - echo "authorEmail=$(git log -1 --pretty=format:'%ae')" >> $GITHUB_OUTPUT - - uses: moonrepo/setup-toolchain@v0 - with: - auto-install: true - - run: moon setup - - run: moon run :build --query "projectSource~packages/*" - - name: Snapshot release - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - SNAPSHOT_TAG: ${{ inputs.tag || steps.split.outputs.fragment }} - USERNAME: ${{ steps.author.outputs.authorName }} - EMAIL: ${{ steps.author.outputs.authorEmail }} - run: | - pnpm changeset version --snapshot $SNAPSHOT_TAG - git config --global user.name "$USERNAME" - git config --global user.email "$EMAIL" - git add . - git commit -m "chore: snapshot release $SNAPSHOT_TAG" - npm set //registry.npmjs.org/:_authToken=$NPM_TOKEN - pnpm changeset publish --tag $SNAPSHOT_TAG - git push origin HEAD - git push --tags + if [ -n "${{ inputs.tag }}" ]; then + echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT + else + echo "tag=${BRANCH##*snapshot-}" >> $GITHUB_OUTPUT + fi + + publish: + needs: get-snapshot-tag + uses: ./.github/workflows/publish-packages.yml + with: + snapshot-tag: ${{ needs.get-snapshot-tag.outputs.snapshot-tag }} + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b327ed4a..3f788883 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,37 +10,6 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: release: - name: Release - runs-on: ubuntu-latest - permissions: - contents: write - id-token: write # Required for npm trusted publishing (OIDC) - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - # Set up Node.js without proto to test OIDC compatibility - - uses: actions/setup-node@v4 - with: - node-version: "20.17.0" - # Install npm 11.6.2 (required for OIDC) - bypassing proto - - run: npm install -g npm@11.6.2 - # Install pnpm directly - - run: npm install -g pnpm@10.17.1 - # Install dependencies - - run: pnpm install --frozen-lockfile - # Build packages directly (bypassing moon - only tokens package has build tasks) - - name: Build tokens package - run: | - cd packages/tokens - node tasks/buildSpectrumTokens.js - node tasks/buildManifest.js - - name: Create Release Pull Request or Publish to npm - id: changesets - uses: GarthDB/changesets-action@v1.6.8 - with: - commit: "chore: release" - publish: pnpm release - oidcAuth: true # Test OIDC without proto shims - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + uses: ./.github/workflows/publish-packages.yml + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/llms.txt b/llms.txt index 6b01c13a..07c47d8b 100644 --- a/llms.txt +++ b/llms.txt @@ -210,6 +210,26 @@ pnpm generateDiffResult 3. **Token Diff Analysis**: Compare token changes between versions 4. **Documentation Generation**: Create docs from schemas 5. **Migration Assistance**: Help with S1 → S2 migrations +6. **Component Building**: Use Agent Skills to build Spectrum components correctly + +### Agent Skills for AI Agents + +The Spectrum Design Data MCP includes **Agent Skills** - markdown guides that help AI agents work with Spectrum design data: + +- **[Component Builder](tools/spectrum-design-data-mcp/agent-skills/component-builder.md)**: Guides agents through building Spectrum components by orchestrating MCP tools to discover schemas, find tokens, and validate configurations +- **[Token Finder](tools/spectrum-design-data-mcp/agent-skills/token-finder.md)**: Helps agents discover the right design tokens for colors, spacing, typography, and component styling + +**How to Use Agent Skills:** +1. When a user asks about building components or finding tokens, read the relevant Agent Skill +2. Follow the step-by-step workflow provided in the skill +3. Call the MCP tools as directed (e.g., `get-component-schema`, `find-tokens-by-use-case`) +4. Combine tool outputs into a complete solution + +**Workflow Pattern:** +- Component Building: `get-component-schema` → `get-component-tokens` → `find-tokens-by-use-case` → `validate-component-props` +- Token Discovery: `get-design-recommendations` → `find-tokens-by-use-case` → `get-token-details` + +See `tools/spectrum-design-data-mcp/agent-skills/README.md` for complete documentation. ### File Patterns to Understand @@ -217,6 +237,7 @@ pnpm generateDiffResult - `packages/component-schemas/schemas/components/*.json` - Component schemas - `docs/*/src/**/*.ts` - Visualization tool source code - `tools/*/src/**/*.js` - Utility tool implementations +- `tools/spectrum-design-data-mcp/agent-skills/*.md` - Agent Skills documentation ### Important Data Structures diff --git a/package.json b/package.json index 330a46fa..fa0cabc4 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,11 @@ "remark-github": "^12.0.0" }, "engines": { - "node": "~20.17" + "node": "20.17.0" }, - "packageManager": "pnpm@10.17.1" + "packageManager": "pnpm@10.17.1", + "devEngines": { + "runtime": "20.17.0", + "packageManager": "pnpm@10.17.1" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a3dad42..8c9bc767 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,6 +620,9 @@ importers: ava: specifier: ^6.0.1 version: 6.4.0(@ava/typescript@6.0.0)(rollup@4.44.1) + c8: + specifier: ^10.1.3 + version: 10.1.3 tools/spectrum-diff-core: dependencies: diff --git a/tools/spectrum-design-data-mcp/README.md b/tools/spectrum-design-data-mcp/README.md index 6a8cb7a6..3f537fa6 100644 --- a/tools/spectrum-design-data-mcp/README.md +++ b/tools/spectrum-design-data-mcp/README.md @@ -69,6 +69,118 @@ The server runs locally and communicates via stdio with MCP-compatible AI client * **`validate-component-props`**: Validate component properties against schemas * **`get-type-schemas`**: Get type definitions used in schemas +#### Workflow Tools + +* **`build-component-config`**: Generate a complete component configuration with recommended tokens and props +* **`suggest-component-improvements`**: Analyze existing component configuration and suggest improvements + +#### Implementation Map Tools (PoC) + +Token-to-implementation mapping for translating Spectrum design tokens into platform-specific style APIs (e.g. React Spectrum S2 style macro). See [RFC: Design Token Sourcemaps and Traceability](https://github.com/adobe/spectrum-design-data/discussions/626). + +* **`resolve-implementation`**: Resolve a Spectrum token name to the equivalent style macro property and value for a platform (e.g. `accent-background-color-default` → `backgroundColor: 'accent'` in React Spectrum) +* **`reverse-lookup-implementation`**: Find Spectrum token name(s) that map to a given platform style macro property and value +* **`list-implementation-mappings`**: List token names that have a known mapping for a platform (useful to see PoC coverage) + +Supported platform: **react-spectrum**. Mapping data: `data/react-spectrum-token-map.json`. + +## Agent Skills + +Agent Skills are markdown guides that help AI agents use the Spectrum Design Data MCP tools effectively. They orchestrate multiple MCP tools into complete workflows for common design system tasks. + +### Available Agent Skills + +* **[Component Builder](agent-skills/component-builder.md)**: Guides agents through building Spectrum components correctly by discovering schemas, finding tokens, and validating configurations +* **[Token Finder](agent-skills/token-finder.md)**: Helps agents discover the right design tokens for colors, spacing, typography, and component styling + +### How Agent Skills Work + +Agent Skills are documentation files that: + +* Guide AI agents through multi-step workflows +* Orchestrate existing MCP tools into complete tasks +* Provide examples and best practices +* Help agents discover the right tools for specific use cases + +Unlike MCP tools (which are executable functions), Agent Skills are **guidance documents** that tell agents how to combine tools to accomplish complex tasks. + +### Using Agent Skills + +For AI agents working with Spectrum components: + +1. Read the relevant Agent Skill when a user asks about a covered task +2. Follow the step-by-step workflow provided +3. Call the MCP tools as directed by the skill +4. Combine tool outputs into a complete solution + +See the [Agent Skills README](agent-skills/README.md) for more details. + +### Related Resources + +This implementation follows the pattern established by [React Spectrum's AI integration](https://react-spectrum.adobe.com/ai), which also uses MCP and Agent Skills to help AI agents work with design systems. + +## Quick Start + +### Building a Component with Agent Skills + +The fastest way to build a Spectrum component is to use the workflow tools: + +#### One-Shot Component Configuration + +```javascript +// Generate complete config with recommended tokens +await buildComponentConfig({ + component: "action-button", + variant: "accent", + intent: "primary", + includeTokens: true, +}); +// Returns: complete config with props, tokens, and validation +``` + +#### Step-by-Step Workflow (following Component Builder skill) + +```javascript +// 1. Get component schema +const schema = await getComponentSchema({ component: "action-button" }); + +// 2. Find related tokens +const tokens = await getComponentTokens({ componentName: "action-button" }); + +// 3. Get color recommendations +const colors = await getDesignRecommendations({ + intent: "primary", + context: "button", +}); + +// 4. Validate configuration +const validation = await validateComponentProps({ + component: "action-button", + props: { variant: "accent", size: "m" }, +}); +``` + +### Finding Design Tokens + +```javascript +// Find tokens by use case +const bgTokens = await findTokensByUseCase({ + useCase: "button background", + componentType: "button", +}); + +// Get semantic color recommendations +const errorColors = await getDesignRecommendations({ + intent: "negative", + context: "text", +}); + +// Get all tokens for a component +const buttonTokens = await getComponentTokens({ + componentName: "action-button", +}); +``` + ## Configuration ### MCP Setup @@ -273,10 +385,15 @@ src/ ├── cli.js # CLI interface ├── tools/ # MCP tool implementations │ ├── tokens.js # Token-related tools -│ └── schemas.js # Schema-related tools -└── data/ # Data access layer - ├── tokens.js # Token data access - └── schemas.js # Schema data access +│ ├── schemas.js # Schema-related tools +│ └── workflows.js # Workflow-oriented tools +├── data/ # Data access layer +│ ├── tokens.js # Token data access +│ └── schemas.js # Schema data access +└── agent-skills/ # Agent Skills documentation + ├── component-builder.md + ├── token-finder.md + └── README.md ``` ## Security diff --git a/tools/spectrum-design-data-mcp/agent-skills/README.md b/tools/spectrum-design-data-mcp/agent-skills/README.md new file mode 100644 index 00000000..f32bfcc4 --- /dev/null +++ b/tools/spectrum-design-data-mcp/agent-skills/README.md @@ -0,0 +1,240 @@ +# Spectrum Design Data Agent Skills + +## Overview + +Agent Skills are markdown guides that help AI agents use the Spectrum Design Data MCP tools effectively. They orchestrate multiple MCP tools into complete workflows for common design system tasks. + +## What are Agent Skills? + +Agent Skills are documentation files that: + +* Guide AI agents through multi-step workflows +* Orchestrate existing MCP tools into complete tasks +* Provide examples and best practices +* Help agents discover the right tools for specific use cases + +Unlike MCP tools (which are executable functions), Agent Skills are **guidance documents** that tell agents how to combine tools to accomplish complex tasks. + +## Available Skills + +### [Component Builder](component-builder.md) + +Helps agents build Spectrum components correctly by: + +* Discovering component schemas +* Finding appropriate design tokens +* Validating component configurations +* Following Spectrum design patterns + +**Use when**: Building, creating, or implementing Spectrum components + +### [Token Finder](token-finder.md) + +Helps agents discover the right design tokens for: + +* Color decisions (semantic, component-specific) +* Spacing and layout +* Typography +* Component styling + +**Use when**: Finding tokens for design decisions or styling components + +### Guides + +* **[State Management](guides/state-management.md)**: Handling component states (default, hover, focus, disabled, selected) and token recommendations per state + +## How Agent Skills Work + +Agent Skills don't execute code directly. Instead, they: + +1. **Guide tool selection**: Tell agents which MCP tools to use +2. **Orchestrate workflows**: Show how to combine multiple tools +3. **Provide examples**: Demonstrate real-world usage patterns +4. **Share best practices**: Help agents make better decisions + +### Example Workflow + +When an agent needs to build a button: + +1. Agent reads `component-builder.md` +2. Skill guides agent to use `get-component-schema` first +3. Then use `get-component-tokens` to find related tokens +4. Use `find-tokens-by-use-case` for specific styling needs +5. Finally use `validate-component-props` to ensure correctness + +The skill provides the **workflow**, while the MCP tools provide the **data**. + +## Integration with MCP Tools + +Agent Skills work alongside the Spectrum Design Data MCP tools: + +### Token Tools + +* `query-tokens` - Search tokens +* `find-tokens-by-use-case` ⭐ - Find tokens for use cases +* `get-component-tokens` ⭐ - Get component-specific tokens +* `get-design-recommendations` ⭐ - Get semantic recommendations +* `get-token-categories` - List categories +* `get-token-details` - Get token details + +### Schema Tools + +* `query-component-schemas` - Search schemas +* `get-component-schema` ⭐ - Get component API +* `list-components` - List all components +* `validate-component-props` ⭐ - Validate configurations +* `get-type-schemas` - Get type definitions +* `get-component-options` ⭐ - User-friendly property discovery +* `search-components-by-feature` ⭐ - Find components by feature + +⭐ = Frequently used in Agent Skills + +## Common Usage Patterns + +### Pattern: Multi-State Component + +When building components with multiple interactive states: + +```javascript +// 1. Get base recommendations +const baseTokens = await getDesignRecommendations({ + intent: "primary", + state: "default", + context: "button", +}); + +// 2. Get hover state +const hoverTokens = await getDesignRecommendations({ + intent: "primary", + state: "hover", + context: "button", +}); + +// 3. Get disabled state +const disabledTokens = await getDesignRecommendations({ + intent: "primary", + state: "disabled", + context: "button", +}); +``` + +### Pattern: Form Validation + +Building form fields with validation: + +```javascript +// 1. Get field schema +const schema = await getComponentSchema({ component: "text-field" }); + +// 2. Get error state tokens +const errorTokens = await findTokensByUseCase({ + useCase: "error state", + componentType: "input", +}); + +// 3. Validate configuration +const validation = await validateComponentProps({ + component: "text-field", + props: { + validationState: "invalid", + errorMessage: "Required field", + }, +}); +``` + +### Pattern: Semantic Color Selection + +Choosing colors based on intent: + +```javascript +// For success messages +const successColors = await getDesignRecommendations({ + intent: "positive", + context: "text", +}); + +// For warnings +const warningColors = await getDesignRecommendations({ + intent: "notice", + context: "background", +}); + +// For errors +const errorColors = await getDesignRecommendations({ + intent: "negative", + context: "border", +}); +``` + +## Using Agent Skills + +### For AI Agents + +1. **Read the skill**: When a user asks about a task covered by a skill, read the relevant skill file +2. **Follow the workflow**: Use the step-by-step guidance +3. **Call MCP tools**: Execute the MCP tools as directed +4. **Combine results**: Synthesize tool outputs into a complete solution + +### For Developers + +1. **Reference in prompts**: Mention Agent Skills when asking agents to work with Spectrum +2. **Link in documentation**: Reference skills in your project documentation +3. **Extend skills**: Create new skills for additional workflows + +## Creating New Agent Skills + +To create a new Agent Skill: + +1. **Identify the workflow**: What multi-step task needs guidance? +2. **Map to MCP tools**: Which existing tools support this workflow? +3. **Write the guide**: Create a markdown file with: + * Overview and when to use + * Step-by-step workflow + * Examples with real use cases + * Best practices + * Related tools reference +4. **Add to this README**: Document the new skill + +### Skill Template + +```markdown +# [Skill Name] Agent Skill + +## Overview +Brief description of what this skill helps with. + +## When to Use +List scenarios when this skill should be activated. + +## Workflow +Step-by-step guidance on using MCP tools. + +## Example +Real-world example showing the workflow. + +## Best Practices +Tips for using this skill effectively. + +## Related Tools +List of MCP tools used by this skill. +``` + +## Relationship to React Spectrum AI + +This implementation follows the pattern established by [React Spectrum's AI integration](https://react-spectrum.adobe.com/ai), which also uses MCP and Agent Skills to help AI agents work with design systems. + +## Contributing + +When adding new Agent Skills: + +* Follow the existing markdown format +* Include clear examples +* Reference specific MCP tools +* Test workflows with real scenarios +* Update this README + +## Resources + +* [Spectrum Design Data MCP README](../README.md) +* [React Spectrum AI](https://react-spectrum.adobe.com/ai) +* [MCP Specification](https://modelcontextprotocol.io) diff --git a/tools/spectrum-design-data-mcp/agent-skills/SKILL.md b/tools/spectrum-design-data-mcp/agent-skills/SKILL.md new file mode 100644 index 00000000..37111774 --- /dev/null +++ b/tools/spectrum-design-data-mcp/agent-skills/SKILL.md @@ -0,0 +1,195 @@ +*** + +name: build-spectrum-components +description: Build Spectrum components and discover design tokens through orchestrated workflows using the Spectrum Design Data MCP server. Use when building UI components, finding design tokens, validating component configurations, or working with Adobe Spectrum design system data. +license: Apache-2.0 +compatibility: Requires MCP client with access to [**@adobe/spectrum-design-data-mcp**](https://github.com/adobe/spectrum-design-data-mcp) server. Works with Cursor, VS Code, Claude Code, and other MCP-compatible clients. +metadata: +author: Adobe +version: "1.0" + +*** + +# Spectrum Design Data Agent Skills + +## Overview + +This skill package provides AI agents with the ability to build Spectrum components and discover design tokens through orchestrated workflows using the Spectrum Design Data MCP server. + +## Capabilities + +### Component Builder Skill + +**Purpose**: Helps AI agents build Spectrum components correctly by orchestrating MCP tools to discover schemas, find tokens, and validate configurations. + +**When to Use**: + +* Building, creating, or implementing Spectrum components +* Need help with component props, variants, or configuration +* Validating component usage +* Understanding component structure or API + +**Workflow**: + +1. Discover component schema using `get-component-schema` +2. Get component-specific tokens using `get-component-tokens` +3. Find tokens for specific use cases using `find-tokens-by-use-case` +4. Get design recommendations using `get-design-recommendations` +5. Validate component configuration using `validate-component-props` + +**Example**: + +``` +User: "Create a primary action button with medium size" + +Agent workflow: +1. get-component-schema({"component": "action-button"}) +2. get-component-tokens({"componentName": "action-button"}) +3. find-tokens-by-use-case({"useCase": "button background", "componentType": "action-button"}) +4. get-design-recommendations({"intent": "primary", "context": "button"}) +5. validate-component-props({"component": "action-button", "props": {...}}) +``` + +**Related Tools**: + +* `get-component-schema` +* `get-component-tokens` +* `find-tokens-by-use-case` +* `get-design-recommendations` +* `validate-component-props` +* `get-component-options` +* `search-components-by-feature` + +### Token Finder Skill + +**Purpose**: Helps AI agents discover the right Spectrum design tokens for design decisions, component styling, and visual design tasks. + +**When to Use**: + +* Finding tokens for colors, spacing, typography, or other design decisions +* Need recommendations for styling components +* Asking "what token should I use for..." +* Need help with design system values + +**Workflow**: + +1. Understand design intent (primary, error, success, etc.) +2. Get design recommendations using `get-design-recommendations` +3. Find tokens by use case using `find-tokens-by-use-case` +4. Get token details using `get-token-details` +5. Explore component tokens using `get-component-tokens` + +**Example**: + +``` +User: "What colors should I use for a primary button?" + +Agent workflow: +1. get-design-recommendations({"intent": "primary", "context": "button"}) +2. find-tokens-by-use-case({"useCase": "button background", "componentType": "button"}) +3. get-token-details({"tokenPath": "accent-color-100"}) +``` + +**Related Tools**: + +* `get-design-recommendations` +* `find-tokens-by-use-case` +* `get-component-tokens` +* `get-token-details` +* `query-tokens` +* `get-token-categories` + +## MCP Tools Available + +### Token Tools + +* `query-tokens` - Search and retrieve design tokens +* `find-tokens-by-use-case` - Find tokens for specific use cases +* `get-component-tokens` - Get component-specific tokens +* `get-design-recommendations` - Get semantic token recommendations +* `get-token-categories` - List all token categories +* `get-token-details` - Get detailed token information + +### Schema Tools + +* `query-component-schemas` - Search component API schemas +* `get-component-schema` - Get complete component schema +* `list-components` - List all available components +* `validate-component-props` - Validate component properties +* `get-type-schemas` - Get type definitions +* `get-component-options` - User-friendly property discovery +* `search-components-by-feature` - Find components by feature + +### Workflow Tools + +* `build-component-config` - Generate complete component configuration +* `suggest-component-improvements` - Analyze and suggest improvements + +## Integration + +### MCP Server Configuration + +Add to your MCP configuration (e.g., `.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "spectrum-design-data": { + "command": "npx", + "args": ["@adobe/spectrum-design-data-mcp"] + } + } +} +``` + +### Using with AI Agents + +1. **Read the skill documentation**: When a user asks about a task covered by a skill, read the relevant skill file (`component-builder.md` or `token-finder.md`) +2. **Follow the workflow**: Use the step-by-step guidance provided +3. **Call MCP tools**: Execute the MCP tools as directed by the skill +4. **Combine results**: Synthesize tool outputs into a complete solution + +## Resources + +* **Component Builder Guide**: [component-builder.md](component-builder.md) +* **Token Finder Guide**: [token-finder.md](token-finder.md) +* **Agent Skills README**: [README.md](README.md) +* **MCP Server README**: [../README.md](../README.md) +* **React Spectrum AI**: + +## Examples + +### Building a Button Component + +``` +User: "Create a primary action button" + +Agent uses Component Builder skill: +1. Gets action-button schema +2. Finds button-related tokens +3. Gets primary color recommendations +4. Validates final configuration +``` + +### Finding Error State Tokens + +``` +User: "What tokens should I use for error messaging?" + +Agent uses Token Finder skill: +1. Gets negative/error color recommendations +2. Finds error state tokens +3. Gets token details for verification +``` + +## Best Practices + +1. **Always validate**: Use `validate-component-props` before finalizing component configurations +2. **Use semantic tokens**: Prefer `get-design-recommendations` for semantic decisions +3. **Check component options**: Use `get-component-options` for user-friendly property discovery +4. **Combine multiple tools**: Don't rely on a single tool - combine schema + tokens + recommendations +5. **Handle states**: For interactive components, consider all states (default, hover, focus, disabled) + +## Related Projects + +This implementation follows the pattern established by [React Spectrum's AI integration](https://react-spectrum.adobe.com/ai), which also uses MCP and Agent Skills to help AI agents work with design systems. diff --git a/tools/spectrum-design-data-mcp/agent-skills/component-builder.md b/tools/spectrum-design-data-mcp/agent-skills/component-builder.md new file mode 100644 index 00000000..4f89f4a4 --- /dev/null +++ b/tools/spectrum-design-data-mcp/agent-skills/component-builder.md @@ -0,0 +1,271 @@ +# Component Builder Agent Skill + +## Overview + +This Agent Skill helps AI agents build Spectrum components correctly by orchestrating multiple MCP tools to discover component schemas, find appropriate tokens, and validate configurations. + +## When to Use + +Activate this skill when: + +* User asks to build, create, or implement a Spectrum component +* User needs help with component props, variants, or configuration +* User wants to validate component usage +* User asks about component structure or API + +## Workflow + +### Step 1: Discover Component Schema + +Use `get-component-schema` to understand the component's API: + +```json +{ + "component": "action-button" +} +``` + +This returns: + +* Available properties +* Required properties +* Property types and enums +* Default values +* Component description + +### Step 2: Get Component-Specific Tokens + +Use `get-component-tokens` to find all tokens related to this component: + +```json +{ + "componentName": "action-button" +} +``` + +This returns tokens organized by category (color, layout, typography, etc.). + +### Step 3: Find Tokens for Specific Use Cases + +For each visual aspect (background, text, border, spacing), use `find-tokens-by-use-case`: + +```json +{ + "useCase": "button background", + "componentType": "action-button" +} +``` + +Common use cases: + +* "button background" - for background colors +* "text color" - for text/foreground colors +* "border" - for border colors and styles +* "spacing" - for padding and margins +* "error state" - for error/negative states +* "hover state" - for interactive states + +### Step 4: Get Design Recommendations + +For semantic decisions, use `get-design-recommendations`: + +```json +{ + "intent": "primary", + "state": "default", + "context": "button" +} +``` + +Common intents: "primary", "secondary", "accent", "negative", "positive", "notice", "informative" +Common states: "default", "hover", "focus", "active", "disabled", "selected" + +### Step 5: Validate Component Configuration + +Before finalizing, use `validate-component-props` to ensure correctness: + +```json +{ + "component": "action-button", + "props": { + "variant": "accent", + "size": "m", + "isDisabled": false + } +} +``` + +## Example: Building an Action Button + +### User Request + +"Create a primary action button with medium size" + +### Agent Workflow + +1. **Get schema**: `get-component-schema` with `{"component": "action-button"}` + * Discover available props: variant, size, isDisabled, etc. + +2. **Get tokens**: `get-component-tokens` with `{"componentName": "action-button"}` + * Find all button-related tokens + +3. **Find background token**: `find-tokens-by-use-case` with `{"useCase": "button background", "componentType": "action-button"}` + * Get recommended background colors + +4. **Get recommendations**: `get-design-recommendations` with `{"intent": "primary", "context": "button"}` + * Get semantic color recommendations + +5. **Build config**: Combine schema props with token recommendations: + ```json + { + "variant": "accent", + "size": "m", + "style": { + "backgroundColor": "accent-color-100", + "color": "text-color-primary" + } + } + ``` + +6. **Validate**: `validate-component-props` to ensure correctness + +## Example: Building a Text Field + +### User Request + +"Create a text input with error state" + +### Agent Workflow + +1. **Get schema**: `get-component-schema` with `{"component": "text-field"}` + * Discover validationError, isRequired, etc. + +2. **Get tokens**: `get-component-tokens` with `{"componentName": "text-field"}` + +3. **Find error tokens**: `find-tokens-by-use-case` with `{"useCase": "error state", "componentType": "input"}` + * Get negative/error color tokens + +4. **Get error recommendations**: `get-design-recommendations` with `{"intent": "negative", "context": "input"}` + * Get semantic error colors + +5. **Build config**: + ```json + { + "validationState": "invalid", + "errorMessage": "Please enter a valid value", + "style": { + "borderColor": "negative-border-color", + "textColor": "negative-color-100" + } + } + ``` + +## Best Practices + +1. **Always validate**: Use `validate-component-props` before finalizing any component configuration +2. **Use semantic tokens**: Prefer `get-design-recommendations` for semantic decisions (primary, error, etc.) +3. **Check component options**: Use `get-component-options` for a user-friendly view of available props +4. **Combine multiple tools**: Don't rely on a single tool - combine schema + tokens + recommendations +5. **Handle states**: For interactive components, consider all states (default, hover, focus, disabled) + +## Common Validation Errors & Solutions + +### Missing Required Props + +**Error:** + +``` +Missing required property: label +``` + +**Solution:** + +* Check schema with `get-component-schema` to see required props +* Use `get-component-options` for user-friendly property list +* Add the required prop to your configuration + +### Invalid Enum Value + +**Error:** + +``` +Property variant value "primary" is not in allowed enum values +``` + +**Solution:** + +* Check available values: `get-component-schema` shows enum options +* Common fix: "primary" → "accent" for accent buttons +* Use `suggest-component-improvements` for recommendations + +### Unknown Property + +**Error:** + +``` +Unknown property: color +``` + +**Solution:** + +* Property might be named differently (e.g., `variant` instead of `color`) +* Check spelling with `get-component-options` +* Remove custom properties not in schema +* Use tokens through style props instead + +## Related Tools + +* `get-component-schema` - Get complete component API +* `get-component-tokens` - Find component-specific tokens +* `find-tokens-by-use-case` - Find tokens for specific use cases +* `get-design-recommendations` - Get semantic token recommendations +* `validate-component-props` - Validate component configuration +* `get-component-options` - User-friendly property discovery +* `search-components-by-feature` - Find components with specific features + +## Common Components + +* **Actions**: `action-button`, `button`, `action-group`, `action-bar` +* **Inputs**: `text-field`, `text-area`, `checkbox`, `radio-group`, `select-box` +* **Containers**: `card`, `popover`, `tray`, `dialog`, `alert-dialog` +* **Navigation**: `breadcrumbs`, `tabs`, `menu`, `side-navigation` +* **Feedback**: `alert-banner`, `toast`, `in-line-alert`, `status-light` + +## Accessibility Checklist + +When building components, ensure: + +### Required Props + +* [ ] Check schema for required ARIA props (`aria-label`, `aria-labelledby`, etc.) +* [ ] Verify keyboard navigation support (focus props, tab order) +* [ ] Include proper role attributes where needed + +### States & Feedback + +* [ ] Disabled state has appropriate ARIA attributes +* [ ] Error states include `aria-invalid` and error message association +* [ ] Loading states use `aria-busy` or `aria-live` regions +* [ ] Selected/checked states properly indicated + +### Visual Design + +* [ ] Color contrast meets WCAG AA standards (use semantic tokens) +* [ ] Focus indicators are clearly visible +* [ ] Interactive elements have adequate touch targets (44x44px minimum) +* [ ] Text sizing follows typography tokens (minimum 16px for body) + +### Testing + +* [ ] Test with screen reader (VoiceOver, NVDA, JAWS) +* [ ] Verify keyboard-only navigation works +* [ ] Check color contrast with tools +* [ ] Validate against component schema with `validate-component-props` + +## Notes + +* Always check if a component exists using `list-components` before building +* Use `get-component-options` with `detailed: true` for comprehensive property information +* For complex components, break down into smaller parts (container, content, actions) +* Consider accessibility: check for required ARIA props in the schema +* Follow Spectrum design patterns: use recommended tokens, not arbitrary values diff --git a/tools/spectrum-design-data-mcp/agent-skills/guides/state-management.md b/tools/spectrum-design-data-mcp/agent-skills/guides/state-management.md new file mode 100644 index 00000000..74239f01 --- /dev/null +++ b/tools/spectrum-design-data-mcp/agent-skills/guides/state-management.md @@ -0,0 +1,214 @@ +# State Management Guide + +This guide helps AI agents handle component states correctly when building Spectrum components. Use `get-design-recommendations` with the `state` parameter to get token recommendations for each interactive state. + +## Component States + +Spectrum components support these common states: + +| State | Description | When to use | +| -------- | ------------------------------ | -------------------------------- | +| default | Resting state, no interaction | Initial render | +| hover | Pointer over the component | Mouse/touch hover | +| focus | Keyboard or programmatic focus | Focus ring, tab navigation | +| active | Pressed / in progress | Click/tap in progress, loading | +| disabled | Not interactive | `isDisabled: true` | +| selected | Toggle or selection state | Checkboxes, tabs, selected items | + +## Token Recommendations by State + +Use `get-design-recommendations` with the appropriate `state` and `context`: + +```json +{ + "intent": "primary", + "state": "default", + "context": "button" +} +``` + +### Buttons + +For action buttons and buttons, request tokens for each state: + +```json +// Default +{ "intent": "accent", "state": "default", "context": "button" } + +// Hover +{ "intent": "accent", "state": "hover", "context": "button" } + +// Focus (focus ring) +{ "intent": "accent", "state": "focus", "context": "button" } + +// Active / pressed +{ "intent": "accent", "state": "active", "context": "button" } + +// Disabled +{ "intent": "accent", "state": "disabled", "context": "button" } +``` + +### Inputs + +For text fields and other inputs: + +```json +// Default +{ "intent": "primary", "state": "default", "context": "input" } + +// Focus (focused field) +{ "intent": "primary", "state": "focus", "context": "input" } + +// Error / invalid +{ "intent": "negative", "state": "default", "context": "input" } + +// Disabled +{ "intent": "primary", "state": "disabled", "context": "input" } +``` + +### Selection Components + +For checkboxes, radio groups, tabs: + +```json +// Unselected +{ "intent": "primary", "state": "default", "context": "button" } + +// Selected +{ "intent": "primary", "state": "selected", "context": "button" } + +// Selected + hover +{ "intent": "primary", "state": "hover", "context": "button" } +``` + +## Interaction Patterns and State Transitions + +### Typical Button Flow + +1. **default** → user hovers → **hover** +2. **hover** → user presses → **active** +3. **active** → user releases → **hover** or **default** +4. **default** → user tabs to element → **focus** +5. **focus** → user blurs → **default** + +Always provide tokens for default, hover, focus, and disabled. Add active and selected when the component supports them. + +### Form Field Flow + +1. **default** → user focuses → **focus** +2. **focus** → validation fails → **default** with error styling (use intent `negative`) +3. **default** → field disabled → **disabled** + +### Best Practices for State Combinations + +1. **Cover all interactive states**: For buttons and links, include default, hover, focus, and disabled at minimum. +2. **Use semantic intents**: Use `intent: "negative"` for errors, `intent: "positive"` for success, `intent: "accent"` for primary actions. +3. **Consistent context**: Keep `context` aligned with the component type (button, input, text, background, border). +4. **Validate with schema**: After building stateful configs, use `validate-component-props` to ensure props like `isDisabled` and `variant` are valid. +5. **One recommendation call per state**: Call `get-design-recommendations` once per state you need; combine results into a single config object. + +## Examples + +### Example: Action Button with All States + +```javascript +// 1. Get recommendations for each state +const defaultTokens = await getDesignRecommendations({ + intent: "accent", + state: "default", + context: "button", +}); +const hoverTokens = await getDesignRecommendations({ + intent: "accent", + state: "hover", + context: "button", +}); +const focusTokens = await getDesignRecommendations({ + intent: "accent", + state: "focus", + context: "button", +}); +const disabledTokens = await getDesignRecommendations({ + intent: "accent", + state: "disabled", + context: "button", +}); + +// 2. Build config with schema props + state tokens +const config = { + component: "action-button", + props: { variant: "accent", size: "m" }, + states: { + default: defaultTokens, + hover: hoverTokens, + focus: focusTokens, + disabled: disabledTokens, + }, +}; + +// 3. Validate +await validateComponentProps({ + component: "action-button", + props: config.props, +}); +``` + +### Example: Text Field with Error State + +```javascript +// Default and focus +const defaultInput = await getDesignRecommendations({ + intent: "primary", + state: "default", + context: "input", +}); +const errorInput = await getDesignRecommendations({ + intent: "negative", + state: "default", + context: "input", +}); + +// Component supports validationState: "invalid" +const config = { + component: "text-field", + props: { + label: "Email", + validationState: "invalid", + errorMessage: "Please enter a valid email", + }, + tokens: { + default: defaultInput, + error: errorInput, + }, +}; +``` + +### Example: Card with Hover + +For containers like cards, use `context: "background"` or component-specific tokens: + +```javascript +const defaultCard = await getDesignRecommendations({ + intent: "secondary", + state: "default", + context: "background", +}); +const hoverCard = await getDesignRecommendations({ + intent: "secondary", + state: "hover", + context: "background", +}); +``` + +## Related Tools + +* `get-design-recommendations` – primary tool for state-based token recommendations +* `find-tokens-by-use-case` – e.g. "hover state", "disabled state", "error state" +* `get-component-tokens` – component-specific tokens that may include state variants +* `get-component-schema` – required props and enums (e.g. `isDisabled`, `validationState`) +* `validate-component-props` – validate final configuration + +## See Also + +* [Component Builder](../component-builder.md) – full workflow for building components +* [Token Finder](../token-finder.md) – discovering tokens by use case and intent diff --git a/tools/spectrum-design-data-mcp/agent-skills/token-finder.md b/tools/spectrum-design-data-mcp/agent-skills/token-finder.md new file mode 100644 index 00000000..e8c7b38f --- /dev/null +++ b/tools/spectrum-design-data-mcp/agent-skills/token-finder.md @@ -0,0 +1,294 @@ +# Token Finder Agent Skill + +## Overview + +This Agent Skill helps AI agents discover the right Spectrum design tokens for design decisions, component styling, and visual design tasks. It orchestrates token discovery tools to find appropriate tokens based on use cases, design intent, and component context. + +## When to Use + +Activate this skill when: + +* User asks about colors, spacing, typography, or other design tokens +* User needs to find tokens for a specific design decision +* User wants recommendations for styling components +* User asks "what token should I use for..." +* User needs help with design system values + +## Workflow + +### Step 1: Understand the Design Intent + +Determine the semantic intent: + +* **Intent**: primary, secondary, accent, negative, positive, notice, informative +* **State**: default, hover, focus, active, disabled, selected +* **Context**: button, input, text, background, border, icon + +### Step 2: Get Design Recommendations + +Use `get-design-recommendations` for semantic decisions: + +```json +{ + "intent": "primary", + "state": "hover", + "context": "button" +} +``` + +This returns high-confidence token recommendations organized by: + +* Colors (semantic and component) +* Layout (spacing, sizing) +* Typography (if text context) + +### Step 3: Find Tokens by Use Case + +For specific use cases, use `find-tokens-by-use-case`: + +```json +{ + "useCase": "button background", + "componentType": "button" +} +``` + +Common use cases: + +* **Colors**: "button background", "text color", "border color", "icon color" +* **Spacing**: "spacing", "padding", "margin", "gap" +* **Typography**: "font", "heading", "body text", "label" +* **States**: "error state", "hover state", "disabled state", "selected state" +* **Components**: "button", "input", "card", "modal" + +### Step 4: Get Token Details + +Once you've identified candidate tokens, use `get-token-details` for complete information: + +```json +{ + "tokenPath": "accent-color-100", + "category": "semantic-color-palette" +} +``` + +This returns: + +* Token value +* Description +* Deprecation status +* Related tokens +* Usage information + +### Step 5: Explore Component Tokens + +For component-specific styling, use `get-component-tokens`: + +```json +{ + "componentName": "button" +} +``` + +This returns all tokens related to a specific component, organized by category. + +## Example: Finding Button Colors + +### User Request + +"What colors should I use for a primary button?" + +### Agent Workflow + +1. **Get recommendations**: `get-design-recommendations` with `{"intent": "primary", "context": "button"}` + * Returns: `accent-color-100`, `accent-color-200`, etc. + +2. **Find by use case**: `find-tokens-by-use-case` with `{"useCase": "button background", "componentType": "button"}` + * Returns component-specific background tokens + +3. **Get details**: `get-token-details` for each recommended token + * Verify values and check for deprecation + +4. **Combine results**: Present both semantic and component-specific options + +### Result + +```json +{ + "recommended": { + "default": "accent-color-100", + "hover": "accent-color-200", + "pressed": "accent-color-300" + }, + "textColor": "text-color-primary", + "borderColor": "accent-border-color" +} +``` + +## Example: Finding Spacing Tokens + +### User Request + +"What spacing should I use between form fields?" + +### Agent Workflow + +1. **Find by use case**: `find-tokens-by-use-case` with `{"useCase": "spacing", "componentType": "input"}` + * Returns layout and spacing tokens + +2. **Get component tokens**: `get-component-tokens` with `{"componentName": "text-field"}` + * Find field-specific spacing tokens + +3. **Get recommendations**: `get-design-recommendations` with `{"context": "spacing"}` + * Get semantic spacing recommendations + +### Result + +```json +{ + "recommended": { + "fieldSpacing": "spacing-300", + "fieldPadding": "spacing-200", + "labelSpacing": "spacing-100" + } +} +``` + +## Example: Finding Error State Tokens + +### User Request + +"What tokens should I use for error messaging?" + +### Agent Workflow + +1. **Get recommendations**: `get-design-recommendations` with `{"intent": "negative", "context": "text"}` + * Returns semantic negative/error colors + +2. **Find by use case**: `find-tokens-by-use-case` with `{"useCase": "error state"}` + * Returns error-specific tokens + +3. **Get details**: `get-token-details` for key tokens + * Verify error color values + +### Result + +```json +{ + "recommended": { + "textColor": "negative-color-100", + "backgroundColor": "negative-background-color-default", + "borderColor": "negative-border-color", + "iconColor": "negative-color-100" + } +} +``` + +## Decision Trees + +### Color Selection + +``` +Is it semantic? (primary, error, success, etc.) + → Use get-design-recommendations + → Intent: primary/secondary/negative/positive/notice + → Context: button/input/text/background/border + +Is it component-specific? + → Use get-component-tokens + → Then find-tokens-by-use-case with componentType + +Is it a specific use case? + → Use find-tokens-by-use-case + → UseCase: button background, text color, border, etc. +``` + +### Spacing Selection + +``` +Is it component-specific? + → Use get-component-tokens + → Look for layout-component tokens + +Is it a general spacing need? + → Use find-tokens-by-use-case + → UseCase: spacing, padding, margin + +Is it for a specific component part? + → Use get-component-tokens + → Then get-token-details for specific tokens +``` + +### Typography Selection + +``` +Is it for headings? + → Use find-tokens-by-use-case + → UseCase: heading, font + +Is it for body text? + → Use find-tokens-by-use-case + → UseCase: body text, font + +Is it component-specific? + → Use get-component-tokens + → Look for typography tokens +``` + +## Best Practices + +1. **Start with semantics**: Use `get-design-recommendations` for semantic decisions (primary, error, etc.) +2. **Narrow with use cases**: Use `find-tokens-by-use-case` to narrow down options +3. **Verify details**: Always use `get-token-details` to check values and deprecation +4. **Consider states**: For interactive elements, get tokens for all states (default, hover, focus, disabled) +5. **Check component context**: Use `get-component-tokens` when styling specific components +6. **Avoid deprecated tokens**: Check `deprecated` flag in token details +7. **Use aliases**: Check for `renamed` property if a token is deprecated + +## Token Categories + +* **Color**: `color-palette`, `color-component`, `semantic-color-palette`, `color-aliases` +* **Layout**: `layout`, `layout-component` +* **Typography**: `typography` +* **Icons**: `icons` + +## Related Tools + +* `get-design-recommendations` - Semantic token recommendations +* `find-tokens-by-use-case` - Find tokens for specific use cases +* `get-component-tokens` - Get component-specific tokens +* `get-token-details` - Get detailed token information +* `query-tokens` - Search tokens by name/type/category +* `get-token-categories` - List all token categories + +## Common Use Cases + +### Colors + +* "button background" → Background colors for buttons +* "text color" → Text/foreground colors +* "border color" → Border colors +* "error state" → Error/negative colors +* "hover state" → Hover state colors + +### Spacing + +* "spacing" → General spacing tokens +* "padding" → Padding tokens +* "margin" → Margin tokens +* "gap" → Gap tokens for flex/grid + +### Typography + +* "heading" → Heading font tokens +* "body text" → Body text tokens +* "label" → Label tokens +* "font" → Font family tokens + +## Notes + +* Always prefer semantic tokens (`semantic-color-palette`) over raw palette tokens +* Check for `private: true` - these are internal tokens not for public use +* Use `renamed` property to find replacement tokens for deprecated ones +* Token values may be references to other tokens (aliases) +* Some tokens are component-specific and should only be used with those components diff --git a/tools/spectrum-design-data-mcp/data/react-spectrum-token-map.json b/tools/spectrum-design-data-mcp/data/react-spectrum-token-map.json new file mode 100644 index 00000000..83b96175 --- /dev/null +++ b/tools/spectrum-design-data-mcp/data/react-spectrum-token-map.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://opensource.adobe.com/spectrum-design-data/token-sourcemap-poc", + "version": 1, + "platform": "react-spectrum", + "description": "Proof-of-concept mapping from @adobe/spectrum-tokens to React Spectrum (S2) style macro. Extracted from @react-spectrum/s2 style/tokens.ts and style/spectrum-theme.ts.", + "mappings": { + "accent-content-color-default": { + "styleMacro": { "property": "color", "value": "accent" } + }, + "neutral-content-color-default": { + "styleMacro": { "property": "color", "value": "neutral" } + }, + "neutral-subdued-content-color-default": { + "styleMacro": { "property": "color", "value": "neutral-subdued" } + }, + "accent-background-color-default": { + "styleMacro": { "property": "backgroundColor", "value": "accent" } + }, + "accent-subtle-background-color-default": { + "styleMacro": { "property": "backgroundColor", "value": "accent-subtle" } + }, + "neutral-background-color-default": { + "styleMacro": { "property": "backgroundColor", "value": "neutral" } + }, + "neutral-subdued-background-color-default": { + "styleMacro": { + "property": "backgroundColor", + "value": "neutral-subdued" + } + }, + "neutral-subtle-background-color-default": { + "styleMacro": { "property": "backgroundColor", "value": "neutral-subtle" } + }, + "informative-background-color-default": { + "styleMacro": { "property": "backgroundColor", "value": "informative" } + }, + "informative-subtle-background-color-default": { + "styleMacro": { + "property": "backgroundColor", + "value": "informative-subtle" + } + }, + "background-base-color": { + "styleMacro": { "property": "backgroundColor", "value": "base" } + }, + "background-layer-1-color": { + "styleMacro": { "property": "backgroundColor", "value": "layer-1" } + }, + "background-layer-2-color": { + "styleMacro": { "property": "backgroundColor", "value": "layer-2" } + }, + "background-pasteboard-color": { + "styleMacro": { "property": "backgroundColor", "value": "pasteboard" } + }, + "background-elevated-color": { + "styleMacro": { "property": "backgroundColor", "value": "elevated" } + }, + "disabled-background-color": { + "styleMacro": { "property": "backgroundColor", "value": "disabled" } + }, + "focus-indicator-color": { + "styleMacro": { "property": "outlineColor", "value": "focus-ring" } + }, + "negative-border-color-default": { + "styleMacro": { "property": "borderColor", "value": "negative" } + }, + "disabled-border-color": { + "styleMacro": { "property": "borderColor", "value": "disabled" } + }, + "accent-visual-color": { + "styleMacro": { "property": "fill", "value": "accent" } + }, + "neutral-visual-color": { + "styleMacro": { "property": "fill", "value": "neutral" } + }, + "informative-visual-color": { + "styleMacro": { "property": "fill", "value": "informative" } + }, + "negative-visual-color": { + "styleMacro": { "property": "fill", "value": "negative" } + }, + "positive-visual-color": { + "styleMacro": { "property": "fill", "value": "positive" } + }, + "notice-visual-color": { + "styleMacro": { "property": "fill", "value": "notice" } + }, + "font-size-50": { + "styleMacro": { "property": "fontSize", "value": "ui-xs" } + }, + "font-size-75": { + "styleMacro": { "property": "fontSize", "value": "ui-sm" } + }, + "font-size-100": { + "styleMacro": { "property": "fontSize", "value": "ui" } + }, + "font-size-200": { + "styleMacro": { "property": "fontSize", "value": "ui-lg" } + }, + "font-size-300": { + "styleMacro": { "property": "fontSize", "value": "ui-xl" } + }, + "font-size-400": { + "styleMacro": { "property": "fontSize", "value": "ui-2xl" } + }, + "font-size-500": { + "styleMacro": { "property": "fontSize", "value": "ui-3xl" } + }, + "heading-size-xxs": { + "styleMacro": { "property": "fontSize", "value": "heading-2xs" } + }, + "heading-size-xs": { + "styleMacro": { "property": "fontSize", "value": "heading-xs" } + }, + "heading-size-s": { + "styleMacro": { "property": "fontSize", "value": "heading-sm" } + }, + "heading-size-m": { + "styleMacro": { "property": "fontSize", "value": "heading" } + }, + "heading-size-l": { + "styleMacro": { "property": "fontSize", "value": "heading-lg" } + }, + "heading-size-xl": { + "styleMacro": { "property": "fontSize", "value": "heading-xl" } + }, + "heading-size-xxl": { + "styleMacro": { "property": "fontSize", "value": "heading-2xl" } + }, + "heading-size-xxxl": { + "styleMacro": { "property": "fontSize", "value": "heading-3xl" } + }, + "body-size-xs": { + "styleMacro": { "property": "fontSize", "value": "body-xs" } + }, + "body-size-s": { + "styleMacro": { "property": "fontSize", "value": "body-sm" } + }, + "body-size-m": { + "styleMacro": { "property": "fontSize", "value": "body" } + }, + "body-size-l": { + "styleMacro": { "property": "fontSize", "value": "body-lg" } + }, + "body-size-xl": { + "styleMacro": { "property": "fontSize", "value": "body-xl" } + }, + "detail-size-s": { + "styleMacro": { "property": "fontSize", "value": "detail-sm" } + }, + "detail-size-m": { + "styleMacro": { "property": "fontSize", "value": "detail" } + }, + "detail-size-l": { + "styleMacro": { "property": "fontSize", "value": "detail-lg" } + } + } +} diff --git a/tools/spectrum-design-data-mcp/moon.yml b/tools/spectrum-design-data-mcp/moon.yml index fce3657a..d6cbfb26 100644 --- a/tools/spectrum-design-data-mcp/moon.yml +++ b/tools/spectrum-design-data-mcp/moon.yml @@ -20,7 +20,15 @@ tasks: command: - pnpm - ava + - test platform: node + inputs: + - "src/**/*" + - "test/**/*" + - "ava.config.js" + - "package.json" + ci: + deps: [test] test-watch: command: - ava diff --git a/tools/spectrum-design-data-mcp/package.json b/tools/spectrum-design-data-mcp/package.json index 785c06da..cef7ba13 100644 --- a/tools/spectrum-design-data-mcp/package.json +++ b/tools/spectrum-design-data-mcp/package.json @@ -26,7 +26,7 @@ "LICENSE" ], "scripts": { - "test": "ava" + "test": "c8 ava" }, "engines": { "node": ">=20.12.0", @@ -56,6 +56,7 @@ "commander": "^13.1.0" }, "devDependencies": { - "ava": "^6.0.1" + "ava": "^6.0.1", + "c8": "^10.1.3" } } diff --git a/tools/spectrum-design-data-mcp/src/config/intent-mappings.js b/tools/spectrum-design-data-mcp/src/config/intent-mappings.js new file mode 100644 index 00000000..3ae4ef54 --- /dev/null +++ b/tools/spectrum-design-data-mcp/src/config/intent-mappings.js @@ -0,0 +1,43 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** Maps user-facing intent names to semantic token name substrings */ +export const INTENT_SEMANTIC_MAPPINGS = { + error: ["negative"], + success: ["positive"], + warning: ["notice"], +}; + +/** Maps use case keywords to token categories to search */ +export const USE_CASE_PATTERNS = { + background: ["color-component", "semantic-color-palette", "color-palette"], + text: ["color-component", "semantic-color-palette", "typography"], + border: ["color-component", "semantic-color-palette"], + spacing: ["layout", "layout-component"], + padding: ["layout", "layout-component"], + margin: ["layout", "layout-component"], + font: ["typography"], + icon: ["icons", "layout"], + error: ["semantic-color-palette", "color-component"], + success: ["semantic-color-palette", "color-component"], + warning: ["semantic-color-palette", "color-component"], + accent: ["semantic-color-palette", "color-component"], + button: ["color-component", "layout-component"], + input: ["color-component", "layout-component"], + card: ["color-component", "layout-component"], +}; + +/** Maps variant names to semantic token name substrings */ +export const VARIANT_MAPPINGS = { + accent: ["accent"], + negative: ["negative"], +}; diff --git a/tools/spectrum-design-data-mcp/src/constants.js b/tools/spectrum-design-data-mcp/src/constants.js new file mode 100644 index 00000000..5ea713b2 --- /dev/null +++ b/tools/spectrum-design-data-mcp/src/constants.js @@ -0,0 +1,32 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +export const RESULT_LIMITS = { + DEFAULT_TOKEN_LIMIT: 50, + DEFAULT_SCHEMA_LIMIT: 20, + MAX_COLOR_RECOMMENDATIONS: 5, + MAX_USE_CASE_TOKENS: 10, + MAX_SEMANTIC_COLORS: 5, + MAX_DESIGN_RECOMMENDATIONS: 10, + MAX_TOKENS_BY_USE_CASE: 20, +}; + +export const TOKEN_FILES = [ + "color-aliases.json", + "color-component.json", + "color-palette.json", + "icons.json", + "layout-component.json", + "layout.json", + "semantic-color-palette.json", + "typography.json", +]; diff --git a/tools/spectrum-design-data-mcp/src/data/react-spectrum-map.js b/tools/spectrum-design-data-mcp/src/data/react-spectrum-map.js new file mode 100644 index 00000000..c46f725a --- /dev/null +++ b/tools/spectrum-design-data-mcp/src/data/react-spectrum-map.js @@ -0,0 +1,73 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use it except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let mapCache = null; + +/** + * Load the React Spectrum token-to-style-macro mapping (PoC). + * @returns {{ version: number, platform: string, mappings: Record }} + */ +export function loadReactSpectrumMap() { + if (mapCache) return mapCache; + const path = join(__dirname, "../../data/react-spectrum-token-map.json"); + const raw = readFileSync(path, "utf-8"); + mapCache = JSON.parse(raw); + return mapCache; +} + +/** + * Resolve a Spectrum token name to React Spectrum style macro property and value. + * @param {string} tokenName - Token name (e.g. "accent-background-color-default", "font-size-100") + * @returns {{ property: string, value: string } | null} + */ +export function resolveTokenToReactSpectrum(tokenName) { + if (!tokenName || typeof tokenName !== "string") return null; + const normalized = String(tokenName).trim(); + if (!normalized) return null; + const { mappings } = loadReactSpectrumMap(); + const entry = mappings[normalized]; + return entry?.styleMacro ?? null; +} + +/** + * Reverse lookup: find Spectrum token name(s) that map to the given style macro property and value. + * @param {string} property - Style macro property (e.g. "backgroundColor", "fontSize") + * @param {string} value - Style macro value (e.g. "accent", "ui") + * @returns {string[]} + */ +export function reverseLookupReactSpectrum(property, value) { + if (!property || !value) return []; + const { mappings } = loadReactSpectrumMap(); + const tokenNames = []; + for (const [tokenName, entry] of Object.entries(mappings)) { + const sm = entry?.styleMacro; + if (sm && sm.property === property && sm.value === value) { + tokenNames.push(tokenName); + } + } + return tokenNames; +} + +/** + * List all token names that have a React Spectrum mapping. + * @returns {string[]} + */ +export function listMappedTokenNames() { + const { mappings } = loadReactSpectrumMap(); + return Object.keys(mappings); +} diff --git a/tools/spectrum-design-data-mcp/src/index.js b/tools/spectrum-design-data-mcp/src/index.js index eb4f0cfc..577d8d77 100644 --- a/tools/spectrum-design-data-mcp/src/index.js +++ b/tools/spectrum-design-data-mcp/src/index.js @@ -19,6 +19,8 @@ import { import { createTokenTools } from "./tools/tokens.js"; import { createSchemaTools } from "./tools/schemas.js"; +import { createWorkflowTools } from "./tools/workflows.js"; +import { createImplementationMapTools } from "./tools/implementation-map.js"; /** * Create and configure the Spectrum Design Data MCP server @@ -28,7 +30,7 @@ export function createMCPServer() { const server = new Server( { name: "spectrum-design-data", - version: "0.1.0", + version: "0.2.0", }, { capabilities: { @@ -38,7 +40,12 @@ export function createMCPServer() { ); // Combine all available tools - const allTools = [...createTokenTools(), ...createSchemaTools()]; + const allTools = [ + ...createTokenTools(), + ...createSchemaTools(), + ...createWorkflowTools(), + ...createImplementationMapTools(), + ]; // Register list_tools handler server.setRequestHandler(ListToolsRequestSchema, async () => { @@ -94,4 +101,9 @@ export async function startServer() { } // Export for testing -export { createTokenTools, createSchemaTools }; +export { + createTokenTools, + createSchemaTools, + createWorkflowTools, + createImplementationMapTools, +}; diff --git a/tools/spectrum-design-data-mcp/src/tools/implementation-map.js b/tools/spectrum-design-data-mcp/src/tools/implementation-map.js new file mode 100644 index 00000000..1be6d52a --- /dev/null +++ b/tools/spectrum-design-data-mcp/src/tools/implementation-map.js @@ -0,0 +1,179 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use it except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + loadReactSpectrumMap, + resolveTokenToReactSpectrum, + reverseLookupReactSpectrum, + listMappedTokenNames, +} from "../data/react-spectrum-map.js"; + +const SUPPORTED_PLATFORMS = ["react-spectrum"]; + +/** + * Create implementation-mapping MCP tools (token → platform style macro). + * @returns {Array<{ name: string, description: string, inputSchema: object, handler: Function }>} + */ +export function createImplementationMapTools() { + return [ + { + name: "resolve-implementation", + description: + "Resolve a Spectrum token name to the equivalent style macro property and value for a given platform (e.g. React Spectrum). Use when you need to know what to use in code for a given design token.", + inputSchema: { + type: "object", + properties: { + platform: { + type: "string", + description: `Target platform. Supported: ${SUPPORTED_PLATFORMS.join(", ")}`, + enum: SUPPORTED_PLATFORMS, + }, + tokenName: { + type: "string", + description: + "Spectrum token name (e.g. accent-background-color-default, font-size-100)", + }, + }, + required: ["platform", "tokenName"], + }, + handler: async (args) => { + const platform = args?.platform; + const tokenName = args?.tokenName; + if (!platform || !tokenName) { + return { + ok: false, + error: "platform and tokenName are required", + }; + } + if (platform !== "react-spectrum") { + return { + ok: false, + error: `Unsupported platform: ${platform}. Supported: ${SUPPORTED_PLATFORMS.join(", ")}`, + }; + } + const styleMacro = resolveTokenToReactSpectrum( + String(tokenName).trim(), + ); + if (!styleMacro) { + return { + ok: false, + platform: "react-spectrum", + tokenName: String(tokenName).trim(), + message: + "No mapping found for this token. It may not be used in React Spectrum style macro or is not yet in the PoC map.", + }; + } + return { + ok: true, + platform: "react-spectrum", + tokenName: String(tokenName).trim(), + styleMacro: { + property: styleMacro.property, + value: styleMacro.value, + }, + usage: `In React Spectrum style macro, set ${styleMacro.property}: '${styleMacro.value}'`, + }; + }, + }, + { + name: "reverse-lookup-implementation", + description: + "Find Spectrum token name(s) that map to a given platform style macro property and value. Use when you have a React Spectrum style value and want to know the source token.", + inputSchema: { + type: "object", + properties: { + platform: { + type: "string", + description: `Platform. Supported: ${SUPPORTED_PLATFORMS.join(", ")}`, + enum: SUPPORTED_PLATFORMS, + }, + property: { + type: "string", + description: + "Style macro property (e.g. backgroundColor, fontSize, outlineColor)", + }, + value: { + type: "string", + description: "Style macro value (e.g. accent, ui, focus-ring)", + }, + }, + required: ["platform", "property", "value"], + }, + handler: async (args) => { + const platform = args?.platform; + const property = args?.property; + const value = args?.value; + if (!platform || !property || !value) { + return { + ok: false, + error: "platform, property, and value are required", + }; + } + if (platform !== "react-spectrum") { + return { + ok: false, + error: `Unsupported platform: ${platform}. Supported: ${SUPPORTED_PLATFORMS.join(", ")}`, + }; + } + const tokenNames = reverseLookupReactSpectrum( + String(property).trim(), + String(value).trim(), + ); + return { + ok: true, + platform: "react-spectrum", + property: String(property).trim(), + value: String(value).trim(), + tokenNames, + message: + tokenNames.length === 0 + ? "No tokens in the PoC map match this style macro." + : undefined, + }; + }, + }, + { + name: "list-implementation-mappings", + description: + "List token names that have a known mapping to a given platform (e.g. React Spectrum). Useful to see what is covered by the PoC map.", + inputSchema: { + type: "object", + properties: { + platform: { + type: "string", + description: `Platform. Supported: ${SUPPORTED_PLATFORMS.join(", ")}`, + enum: SUPPORTED_PLATFORMS, + }, + }, + required: ["platform"], + }, + handler: async (args) => { + const platform = args?.platform; + if (!platform || platform !== "react-spectrum") { + return { + ok: false, + error: `Unsupported platform: ${platform ?? "missing"}. Supported: ${SUPPORTED_PLATFORMS.join(", ")}`, + }; + } + const { version, mappings } = loadReactSpectrumMap(); + const tokenNames = listMappedTokenNames(); + return { + ok: true, + platform: "react-spectrum", + version, + count: tokenNames.length, + tokenNames, + }; + }, + }, + ]; +} diff --git a/tools/spectrum-design-data-mcp/src/tools/schemas.js b/tools/spectrum-design-data-mcp/src/tools/schemas.js index 1e4f2e2f..35fe8741 100644 --- a/tools/spectrum-design-data-mcp/src/tools/schemas.js +++ b/tools/spectrum-design-data-mcp/src/tools/schemas.js @@ -11,6 +11,13 @@ governing permissions and limitations under the License. */ import { getSchemaData } from "../data/schemas.js"; +import { RESULT_LIMITS } from "../constants.js"; +import { + validateComponentName, + validateLimit, + validatePropsObject, + validateStringParam, +} from "../utils/validation.js"; /** * Create schema-related MCP tools @@ -42,24 +49,41 @@ export function createSchemaTools() { }, }, handler: async (args) => { - const { component, query, limit = 20 } = args; - const schemaData = await getSchemaData(); + const component = validateStringParam(args?.component, "component"); + const query = validateStringParam(args?.query, "query"); + const limit = validateLimit( + args?.limit, + RESULT_LIMITS.DEFAULT_SCHEMA_LIMIT, + 100, + ); + const schemaData = await getSchemaData(); let results = []; - // Search through component schemas - for (const [fileName, schema] of Object.entries( - schemaData.components, - )) { - const componentName = fileName.replace(".json", ""); + const components = + schemaData?.components != null && + typeof schemaData.components === "object" + ? schemaData.components + : {}; - // Apply component filter - if (component && !componentName.includes(component.toLowerCase())) { + for (const [fileName, schema] of Object.entries(components)) { + if (!schema || typeof schema !== "object") continue; + + const componentName = String(fileName).replace(".json", ""); + + if ( + component != null && + component !== "" && + !componentName.toLowerCase().includes(component.toLowerCase()) + ) { continue; } - // Apply query filter - if (query && !matchesSchemaQuery(componentName, schema, query)) { + if ( + query != null && + query !== "" && + !matchesSchemaQuery(componentName, schema, query) + ) { continue; } @@ -74,7 +98,6 @@ export function createSchemaTools() { }); } - // Apply limit results = results.slice(0, limit); return { @@ -99,14 +122,21 @@ export function createSchemaTools() { required: ["component"], }, handler: async (args) => { - const { component } = args; - const schemaData = await getSchemaData(); + const component = validateComponentName(args?.component); + const schemaData = await getSchemaData(); const fileName = `${component}.json`; - const schema = schemaData.components[fileName]; + const schema = + schemaData?.components != null + ? schemaData.components[fileName] + : undefined; - if (!schema) { - throw new Error(`Component schema not found: ${component}`); + if (!schema || typeof schema !== "object") { + throw new Error( + `Component schema not found: ${component}. ` + + "Use list-components to see all available components, or " + + "check https://spectrum.adobe.com/page/components for documentation.", + ); } return { @@ -130,25 +160,33 @@ export function createSchemaTools() { }, handler: async () => { const schemaData = await getSchemaData(); + const components = + schemaData?.components != null && + typeof schemaData.components === "object" + ? schemaData.components + : {}; + + const list = Object.keys(components).map((fileName) => { + const componentName = String(fileName).replace(".json", ""); + const schema = components[fileName]; + const props = schema?.properties; + const required = schema?.required; - const components = Object.keys(schemaData.components).map( - (fileName) => { - const componentName = fileName.replace(".json", ""); - const schema = schemaData.components[fileName]; - - return { - name: componentName, - title: schema.title, - description: schema.description, - propertyCount: Object.keys(schema.properties || {}).length, - hasRequired: (schema.required || []).length > 0, - }; - }, - ); + return { + name: componentName, + title: schema?.title, + description: schema?.description, + propertyCount: + props && typeof props === "object" + ? Object.keys(props).length + : 0, + hasRequired: Array.isArray(required) && required.length > 0, + }; + }); return { - total: components.length, - components: components.sort((a, b) => a.name.localeCompare(b.name)), + total: list.length, + components: list.sort((a, b) => a.name.localeCompare(b.name)), }; }, }, @@ -172,17 +210,27 @@ export function createSchemaTools() { required: ["component", "props"], }, handler: async (args) => { - const { component, props } = args; - const schemaData = await getSchemaData(); + const component = validateComponentName(args?.component); + const props = validatePropsObject(args?.props); + const schemaData = await getSchemaData(); const fileName = `${component}.json`; - const schema = schemaData.components[fileName]; + const schema = + schemaData?.components != null + ? schemaData.components[fileName] + : undefined; - if (!schema) { - throw new Error(`Component schema not found: ${component}`); + if (!schema || typeof schema !== "object") { + throw new Error( + `Component schema not found: ${component}. ` + + "This might mean: " + + "1. The component name is misspelled (use list-components to verify), " + + "2. The component doesn't have a schema yet, " + + "3. The component is from a different package. " + + "Try: get-component-options to explore available options.", + ); } - // Basic validation logic const validationResults = validateProps(props, schema); return { @@ -207,13 +255,19 @@ export function createSchemaTools() { }, }, handler: async (args) => { - const { type } = args; + const type = validateStringParam(args?.type, "type"); const schemaData = await getSchemaData(); - if (type) { - const typeSchema = schemaData.types[`${type}.json`]; - if (!typeSchema) { - throw new Error(`Type schema not found: ${type}`); + if (type != null && type !== "") { + const typeSchema = + schemaData?.types != null + ? schemaData.types[`${type}.json`] + : undefined; + if (!typeSchema || typeof typeSchema !== "object") { + throw new Error( + `Type schema not found: ${type}. ` + + "Use get-type-schemas to list all available type definitions.", + ); } return { @@ -222,16 +276,18 @@ export function createSchemaTools() { }; } - // Return all types - const types = Object.keys(schemaData.types).map((fileName) => { - const typeName = fileName.replace(".json", ""); - const typeSchema = schemaData.types[fileName]; - + const typesData = + schemaData?.types != null && typeof schemaData.types === "object" + ? schemaData.types + : {}; + const types = Object.keys(typesData).map((fileName) => { + const typeName = String(fileName).replace(".json", ""); + const typeSchema = typesData[fileName]; return { name: typeName, - title: typeSchema.title, - description: typeSchema.description, - type: typeSchema.type, + title: typeSchema?.title, + description: typeSchema?.description, + type: typeSchema?.type, }; }); @@ -264,13 +320,17 @@ export function createSchemaTools() { required: ["component"], }, handler: async (args) => { - const { component, detailed = false } = args; - const schemaData = await getSchemaData(); + const component = validateComponentName(args?.component); + const detailed = args?.detailed === true; + const schemaData = await getSchemaData(); const fileName = `${component}.json`; - const schema = schemaData.components[fileName]; + const schema = + schemaData?.components != null + ? schemaData.components[fileName] + : undefined; - if (!schema) { + if (!schema || typeof schema !== "object") { throw new Error( `Component not found: ${component}. Use list-components to see available components.`, ); @@ -278,45 +338,48 @@ export function createSchemaTools() { const componentInfo = { name: component, - title: schema.title || component, + title: schema.title ?? component, description: schema.description, totalProperties: 0, properties: [], }; - if (schema.properties) { - componentInfo.totalProperties = Object.keys(schema.properties).length; + const props = schema.properties; + if (props && typeof props === "object") { + componentInfo.totalProperties = Object.keys(props).length; + const required = schema.required || []; + + for (const [propName, propDef] of Object.entries(props)) { + if (!propDef || typeof propDef !== "object") continue; - Object.entries(schema.properties).forEach(([propName, propDef]) => { const propInfo = { name: propName, - type: propDef.type || "object", - required: schema.required - ? schema.required.includes(propName) - : false, + type: propDef.type ?? "object", + required: required.includes(propName), description: propDef.description, }; if (detailed) { - // Add detailed information - if (propDef.enum) { + if (Array.isArray(propDef.enum)) { propInfo.possibleValues = propDef.enum; } if (propDef.default !== undefined) { propInfo.defaultValue = propDef.default; } - if (propDef.properties) { + if ( + propDef.properties && + typeof propDef.properties === "object" + ) { propInfo.nestedProperties = Object.keys(propDef.properties); } - if (propDef.$ref) { + if (propDef.$ref != null) { propInfo.reference = propDef.$ref; } } componentInfo.properties.push(propInfo); - }); + } - // Sort properties: required first, then alphabetical componentInfo.properties.sort((a, b) => { if (a.required !== b.required) return a.required ? -1 : 1; return a.name.localeCompare(b.name); @@ -343,33 +406,51 @@ export function createSchemaTools() { required: ["feature"], }, handler: async (args) => { - const { feature } = args; + const feature = + args?.feature != null ? String(args.feature) : undefined; + if (!feature || feature.trim() === "") { + throw new Error( + "feature is required for component search. " + + "Provide a feature to search for (e.g., 'validation', 'icon', 'selection'). " + + "Common features: validation, disabled, icon, selection, loading, error.", + ); + } + const schemaData = await getSchemaData(); const matchingComponents = []; + const components = + schemaData?.components != null && + typeof schemaData.components === "object" + ? schemaData.components + : {}; + const featureLower = feature.toLowerCase(); - Object.entries(schemaData.components).forEach(([fileName, schema]) => { - const componentName = fileName.replace(".json", ""); + for (const [fileName, schema] of Object.entries(components)) { + if (!schema || typeof schema !== "object") continue; - if (schema.properties) { - const hasFeature = Object.keys(schema.properties).some((prop) => - prop.toLowerCase().includes(feature.toLowerCase()), + const componentName = String(fileName).replace(".json", ""); + const props = schema.properties; + + if (!props || typeof props !== "object") continue; + + const hasFeature = Object.keys(props).some((prop) => + prop.toLowerCase().includes(featureLower), + ); + + if (hasFeature) { + const matchingProps = Object.keys(props).filter((prop) => + prop.toLowerCase().includes(featureLower), ); - if (hasFeature) { - const matchingProps = Object.keys(schema.properties).filter( - (prop) => prop.toLowerCase().includes(feature.toLowerCase()), - ); - - matchingComponents.push({ - name: componentName, - title: schema.title || componentName, - description: schema.description, - matchingProperties: matchingProps, - totalProperties: Object.keys(schema.properties).length, - }); - } + matchingComponents.push({ + name: componentName, + title: schema.title ?? componentName, + description: schema.description, + matchingProperties: matchingProps, + totalProperties: Object.keys(props).length, + }); } - }); + } return { feature, @@ -391,29 +472,29 @@ export function createSchemaTools() { * @returns {boolean} Whether the schema matches */ function matchesSchemaQuery(componentName, schema, query) { - const searchText = query.toLowerCase(); + const searchText = String(query).toLowerCase(); - // Search in component name if (componentName.toLowerCase().includes(searchText)) { return true; } - // Search in title - if (schema.title && schema.title.toLowerCase().includes(searchText)) { + if ( + schema?.title != null && + String(schema.title).toLowerCase().includes(searchText) + ) { return true; } - // Search in description if ( - schema.description && - schema.description.toLowerCase().includes(searchText) + schema?.description != null && + String(schema.description).toLowerCase().includes(searchText) ) { return true; } - // Search in property names - if (schema.properties) { - for (const propName of Object.keys(schema.properties)) { + const props = schema?.properties; + if (props && typeof props === "object") { + for (const propName of Object.keys(props)) { if (propName.toLowerCase().includes(searchText)) { return true; } @@ -425,33 +506,30 @@ function matchesSchemaQuery(componentName, schema, query) { /** * Basic validation of properties against schema - * @param {Object} props - Properties to validate + * @param {Record} props - Properties to validate * @param {Object} schema - Schema to validate against * @returns {Object} Validation results */ function validateProps(props, schema) { const errors = []; const warnings = []; + const required = schema?.required || []; + const schemaProps = schema?.properties || {}; - // Check required properties - const required = schema.required || []; for (const requiredProp of required) { if (!(requiredProp in props)) { errors.push(`Missing required property: ${requiredProp}`); } } - // Check property types (basic validation) - const schemaProps = schema.properties || {}; for (const [propName, propValue] of Object.entries(props)) { const propSchema = schemaProps[propName]; - if (!propSchema) { + if (!propSchema || typeof propSchema !== "object") { warnings.push(`Unknown property: ${propName}`); continue; } - // Basic type checking if (propSchema.type) { const expectedType = propSchema.type; const actualType = Array.isArray(propValue) ? "array" : typeof propValue; diff --git a/tools/spectrum-design-data-mcp/src/tools/tokens.js b/tools/spectrum-design-data-mcp/src/tools/tokens.js index 54f5faa0..0abedf7f 100644 --- a/tools/spectrum-design-data-mcp/src/tools/tokens.js +++ b/tools/spectrum-design-data-mcp/src/tools/tokens.js @@ -11,6 +11,10 @@ governing permissions and limitations under the License. */ import { getTokenData } from "../data/tokens.js"; +import { RESULT_LIMITS } from "../constants.js"; +import { USE_CASE_PATTERNS } from "../config/intent-mappings.js"; +import { validateLimit, validateStringParam } from "../utils/validation.js"; +import { tokenNameMatchesIntent } from "../utils/token-helpers.js"; /** * Create token-related MCP tools @@ -47,27 +51,38 @@ export function createTokenTools() { }, }, handler: async (args) => { - const { query, category, type, limit = 50 } = args; - const tokenData = await getTokenData(); + const query = validateStringParam(args?.query, "query"); + const category = validateStringParam(args?.category, "category"); + const type = validateStringParam(args?.type, "type"); + const limit = validateLimit( + args?.limit, + RESULT_LIMITS.DEFAULT_TOKEN_LIMIT, + 100, + ); + const tokenData = await getTokenData(); let results = []; - // Search through all token files - for (const [fileName, tokens] of Object.entries(tokenData)) { - // Skip if category filter doesn't match - if ( - category && - !fileName.toLowerCase().includes(category.toLowerCase()) - ) { - continue; + if (tokenData && typeof tokenData === "object") { + for (const [fileName, tokens] of Object.entries(tokenData)) { + if ( + category && + !String(fileName).toLowerCase().includes(category.toLowerCase()) + ) { + continue; + } + if (!tokens || typeof tokens !== "object") continue; + + const processedTokens = processTokens( + tokens, + fileName, + query ?? "", + type, + ); + results.push(...processedTokens); } - - // Process tokens based on their structure - const processedTokens = processTokens(tokens, fileName, query, type); - results.push(...processedTokens); } - // Apply limit results = results.slice(0, limit); return { @@ -87,10 +102,12 @@ export function createTokenTools() { }, handler: async () => { const tokenData = await getTokenData(); - const categories = Object.keys(tokenData).map((fileName) => { - // Extract category from filename (e.g., "color-palette.json" -> "color-palette") - return fileName.replace(".json", ""); - }); + const categories = + tokenData && typeof tokenData === "object" + ? Object.keys(tokenData).map((fileName) => + String(fileName).replace(".json", ""), + ) + : []; return { categories, @@ -118,29 +135,50 @@ export function createTokenTools() { required: ["tokenPath"], }, handler: async (args) => { - const { tokenPath, category } = args; - const tokenData = await getTokenData(); + const tokenPath = + args?.tokenPath != null ? String(args.tokenPath) : undefined; + const category = validateStringParam(args?.category, "category"); + + if (!tokenPath || tokenPath.trim() === "") { + throw new Error( + "tokenPath is required to get token details. " + + "Provide the token name (e.g., 'accent-color-100'). " + + "Use query-tokens or find-tokens-by-use-case to discover token names.", + ); + } - // Search for the token in all categories or specific category - const categoriesToSearch = category - ? [category] - : Object.keys(tokenData); + const tokenData = await getTokenData(); + const categoriesToSearch = + category != null && category !== "" + ? [category] + : tokenData && typeof tokenData === "object" + ? Object.keys(tokenData) + : []; for (const cat of categoriesToSearch) { - const categoryData = tokenData[cat + ".json"] || tokenData[cat]; - if (!categoryData) continue; + const key = cat.endsWith(".json") ? cat : `${cat}.json`; + const categoryData = tokenData?.[key] ?? tokenData?.[cat]; + if (!categoryData || typeof categoryData !== "object") continue; const token = findTokenByPath(categoryData, tokenPath); if (token) { return { path: tokenPath, - category: cat, + category: cat.replace(".json", ""), token, }; } } - throw new Error(`Token not found: ${tokenPath}`); + throw new Error( + `Token not found: ${tokenPath}. ` + + "This might mean: " + + "1. The token name is misspelled, " + + "2. The token is in a different category than specified, " + + "3. The token has been deprecated or renamed. " + + "Try: query-tokens to search for similar tokens, or " + + "get-token-categories to see all available categories.", + ); }, }, { @@ -163,40 +201,29 @@ export function createTokenTools() { }, required: ["useCase"], }, - handler: async ({ useCase, componentType }) => { + handler: async (args) => { + const useCase = + args?.useCase != null ? String(args.useCase) : undefined; + const componentType = validateStringParam( + args?.componentType, + "componentType", + ); + + if (!useCase || useCase.trim() === "") { + throw new Error( + "useCase is required to find tokens. " + + "Describe what you're looking for (e.g., 'button background', 'error state', 'spacing'). " + + "Common use cases: button background, text color, border, spacing, error state, hover state.", + ); + } + const data = await getTokenData(); const recommendations = []; - - // Smart token recommendations based on use case const useCaseLower = useCase.toLowerCase(); - const compTypeLower = (componentType || "").toLowerCase(); - - // Define use case mappings - const useCasePatterns = { - background: [ - "color-component", - "semantic-color-palette", - "color-palette", - ], - text: ["color-component", "semantic-color-palette", "typography"], - border: ["color-component", "semantic-color-palette"], - spacing: ["layout", "layout-component"], - padding: ["layout", "layout-component"], - margin: ["layout", "layout-component"], - font: ["typography"], - icon: ["icons", "layout"], - error: ["semantic-color-palette", "color-component"], - success: ["semantic-color-palette", "color-component"], - warning: ["semantic-color-palette", "color-component"], - accent: ["semantic-color-palette", "color-component"], - button: ["color-component", "layout-component"], - input: ["color-component", "layout-component"], - card: ["color-component", "layout-component"], - }; + const compTypeLower = (componentType ?? "").toLowerCase(); - // Find relevant categories const relevantCategories = []; - for (const [pattern, categories] of Object.entries(useCasePatterns)) { + for (const [pattern, categories] of Object.entries(USE_CASE_PATTERNS)) { if ( useCaseLower.includes(pattern) || compTypeLower.includes(pattern) @@ -205,27 +232,30 @@ export function createTokenTools() { } } - // If no specific patterns match, search all categories const categoriesToSearch = relevantCategories.length > 0 ? [...new Set(relevantCategories)] - : Object.keys(data); + : data && typeof data === "object" + ? Object.keys(data) + : []; - // Search within relevant categories for (const category of categoriesToSearch) { const filename = category.includes(".json") ? category : `${category}.json`; - const tokens = data[filename]; - if (!tokens) continue; + const tokens = data?.[filename]; + if (!tokens || typeof tokens !== "object") continue; + + for (const [name, token] of Object.entries(tokens)) { + if (!token || typeof token !== "object") continue; - Object.entries(tokens).forEach(([name, token]) => { const nameMatch = name.toLowerCase().includes(useCaseLower) || - (componentType && name.toLowerCase().includes(compTypeLower)); + (componentType != null && + name.toLowerCase().includes(compTypeLower)); const descMatch = - token.description && - token.description.toLowerCase().includes(useCaseLower); + token.description != null && + String(token.description).toLowerCase().includes(useCaseLower); if (nameMatch || descMatch) { recommendations.push({ @@ -235,17 +265,16 @@ export function createTokenTools() { description: token.description, schema: token.$schema, uuid: token.uuid, - private: token.private || false, - deprecated: token.deprecated || false, + private: token.private === true, + deprecated: token.deprecated === true, deprecated_comment: token.deprecated_comment, renamed: token.renamed, relevanceReason: nameMatch ? "name match" : "description match", }); } - }); + } } - // Sort by relevance (non-private first, then by name match) recommendations.sort((a, b) => { if (a.private !== b.private) return a.private ? 1 : -1; if (a.relevanceReason !== b.relevanceReason) { @@ -256,8 +285,11 @@ export function createTokenTools() { return { useCase, - componentType, - recommendations: recommendations.slice(0, 20), // Limit to top 20 + componentType: componentType ?? undefined, + recommendations: recommendations.slice( + 0, + RESULT_LIMITS.MAX_TOKENS_BY_USE_CASE, + ), totalFound: recommendations.length, searchedCategories: categoriesToSearch, }; @@ -277,39 +309,51 @@ export function createTokenTools() { }, required: ["componentName"], }, - handler: async ({ componentName }) => { + handler: async (args) => { + const componentName = + args?.componentName != null ? String(args.componentName) : undefined; + if (!componentName || componentName.trim() === "") { + throw new Error( + "componentName is required to get component tokens. " + + "Provide the component name (e.g., 'action-button', 'text-field'). " + + "Use list-components to see all available components.", + ); + } + const data = await getTokenData(); const componentTokens = []; const componentLower = componentName.toLowerCase(); - // Search through all token categories for component-specific tokens - Object.entries(data).forEach(([category, tokens]) => { - if (!tokens) return; + if (data && typeof data === "object") { + for (const [cat, tokens] of Object.entries(data)) { + if (!tokens || typeof tokens !== "object") continue; + + for (const [name, token] of Object.entries(tokens)) { + if (!token || typeof token !== "object") continue; + if (!name.toLowerCase().includes(componentLower)) continue; - Object.entries(tokens).forEach(([name, token]) => { - if (name.toLowerCase().includes(componentLower)) { componentTokens.push({ name, - category, + category: cat, value: token.value, description: token.description, schema: token.$schema, uuid: token.uuid, - private: token.private || false, - deprecated: token.deprecated || false, + private: token.private === true, + deprecated: token.deprecated === true, deprecated_comment: token.deprecated_comment, renamed: token.renamed, }); } - }); - }); + } + } - // Group by category for better organization const groupedTokens = componentTokens.reduce((acc, token) => { - if (!acc[token.category]) acc[token.category] = []; - acc[token.category].push(token); + const category = token.category; + if (!acc[category]) acc[category] = []; + acc[category].push(token); return acc; - }, {}); + }, /** @type {Record>} */ ({})); return { componentName, @@ -343,7 +387,19 @@ export function createTokenTools() { }, required: ["intent"], }, - handler: async ({ intent, state, context }) => { + handler: async (args) => { + const intent = args?.intent != null ? String(args.intent) : undefined; + const state = validateStringParam(args?.state, "state"); + const context = validateStringParam(args?.context, "context"); + + if (!intent || intent.trim() === "") { + throw new Error( + "intent is required for design recommendations. " + + "Provide a design intent (e.g., 'primary', 'negative', 'positive'). " + + "Common intents: primary, secondary, accent, negative, positive, notice, informative.", + ); + } + const data = await getTokenData(); const recommendations = { colors: [], @@ -352,127 +408,125 @@ export function createTokenTools() { }; const intentLower = intent.toLowerCase(); - const stateLower = (state || "").toLowerCase(); - const contextLower = (context || "").toLowerCase(); - - // Search semantic colors first (these are typically the best recommendations) - const semanticColors = data["semantic-color-palette.json"] || {}; - Object.entries(semanticColors).forEach(([name, token]) => { - const nameLower = name.toLowerCase(); - - // Intent matching - const intentMatch = - nameLower.includes(intentLower) || - (intentLower === "error" && nameLower.includes("negative")) || - (intentLower === "success" && nameLower.includes("positive")) || - (intentLower === "warning" && nameLower.includes("notice")); - - // State matching - const stateMatch = !state || nameLower.includes(stateLower); - - // Context matching - const contextMatch = !context || nameLower.includes(contextLower); - - if (intentMatch && stateMatch && contextMatch) { - recommendations.colors.push({ - name, - value: token.value, - category: "semantic-color-palette", - type: "semantic", - confidence: "high", - }); - } - }); + const stateLower = (state ?? "").toLowerCase(); + const contextLower = (context ?? "").toLowerCase(); - // Search component colors if semantic colors don't provide enough options - if (recommendations.colors.length < 3) { - const componentColors = data["color-component.json"] || {}; - Object.entries(componentColors).forEach(([name, token]) => { + const semanticColors = data?.["semantic-color-palette.json"] ?? {}; + if (semanticColors && typeof semanticColors === "object") { + for (const [name, token] of Object.entries(semanticColors)) { + if (!token || typeof token !== "object") continue; const nameLower = name.toLowerCase(); - // Intent and context matching for component colors - const intentMatch = nameLower.includes(intentLower); - const contextMatch = !context || nameLower.includes(contextLower); - const stateMatch = !state || nameLower.includes(stateLower); + const intentMatch = tokenNameMatchesIntent(nameLower, intentLower); + const stateMatch = + !state || state === "" || nameLower.includes(stateLower); + const contextMatch = + !context || context === "" || nameLower.includes(contextLower); - if ((intentMatch || contextMatch) && stateMatch) { + if (intentMatch && stateMatch && contextMatch) { recommendations.colors.push({ name, value: token.value, - category: "color-component", - type: "component", - confidence: "medium", + category: "semantic-color-palette", + type: "semantic", + confidence: "high", }); } - }); + } } - // Layout recommendations if context suggests spacing/sizing - if ( - context && - ["button", "input", "spacing", "padding", "margin"].some((c) => - contextLower.includes(c), - ) - ) { - const layoutComponent = data["layout-component.json"] || {}; - Object.entries(layoutComponent).forEach(([name, token]) => { - const nameLower = name.toLowerCase(); - - if ( - contextLower && - nameLower.includes(contextLower) && - nameLower.includes(stateLower || "size") - ) { - recommendations.layout.push({ - name, - value: token.value, - category: "layout-component", - type: "spacing", - confidence: "high", - }); + if (recommendations.colors.length < 3) { + const componentColors = data?.["color-component.json"] ?? {}; + if (componentColors && typeof componentColors === "object") { + for (const [name, token] of Object.entries(componentColors)) { + if (!token || typeof token !== "object") continue; + const nameLower = name.toLowerCase(); + + const intentMatch = nameLower.includes(intentLower); + const contextMatch = + !context || context === "" || nameLower.includes(contextLower); + const stateMatch = + !state || state === "" || nameLower.includes(stateLower); + + if ((intentMatch || contextMatch) && stateMatch) { + recommendations.colors.push({ + name, + value: token.value, + category: "color-component", + type: "component", + confidence: "medium", + }); + } } - }); + } } - // Typography recommendations for text contexts - if ( - context && - ["text", "label", "heading", "body"].some((c) => - contextLower.includes(c), - ) - ) { - const typography = data["typography.json"] || {}; - Object.entries(typography).forEach(([name, token]) => { - const nameLower = name.toLowerCase(); + const layoutContexts = [ + "button", + "input", + "spacing", + "padding", + "margin", + ]; + if (context && layoutContexts.some((c) => contextLower.includes(c))) { + const layoutComponent = data?.["layout-component.json"] ?? {}; + if (layoutComponent && typeof layoutComponent === "object") { + for (const [name, token] of Object.entries(layoutComponent)) { + if (!token || typeof token !== "object") continue; + const nameLower = name.toLowerCase(); + + if ( + contextLower && + nameLower.includes(contextLower) && + nameLower.includes(stateLower || "size") + ) { + recommendations.layout.push({ + name, + value: token.value, + category: "layout-component", + type: "spacing", + confidence: "high", + }); + } + } + } + } - if (nameLower.includes(contextLower)) { - recommendations.typography.push({ - name, - value: token.value, - category: "typography", - type: "text", - confidence: "high", - }); + const textContexts = ["text", "label", "heading", "body"]; + if (context && textContexts.some((c) => contextLower.includes(c))) { + const typography = data?.["typography.json"] ?? {}; + if (typography && typeof typography === "object") { + for (const [name, token] of Object.entries(typography)) { + if (!token || typeof token !== "object") continue; + const nameLower = name.toLowerCase(); + + if (nameLower.includes(contextLower)) { + recommendations.typography.push({ + name, + value: token.value, + category: "typography", + type: "text", + confidence: "high", + }); + } } - }); + } } - // Sort by confidence and limit results - ["colors", "layout", "typography"].forEach((category) => { + const confidenceOrder = { high: 0, medium: 1, low: 2 }; + for (const category of ["colors", "layout", "typography"]) { recommendations[category] = recommendations[category] - .sort((a, b) => { - const confidenceOrder = { high: 0, medium: 1, low: 2 }; - return ( - confidenceOrder[a.confidence] - confidenceOrder[b.confidence] - ); - }) - .slice(0, 10); - }); + .sort( + (a, b) => + confidenceOrder[a.confidence] - confidenceOrder[b.confidence], + ) + .slice(0, RESULT_LIMITS.MAX_DESIGN_RECOMMENDATIONS); + } return { intent, - state, - context, + state: state ?? undefined, + context: context ?? undefined, recommendations, totalFound: recommendations.colors.length + @@ -489,29 +543,33 @@ export function createTokenTools() { * @param {Object} tokens - Token data structure * @param {string} fileName - Name of the token file * @param {string} query - Search query - * @param {string} type - Type filter + * @param {string|undefined} type - Type filter * @returns {Array} Processed tokens */ function processTokens(tokens, fileName, query, type) { const results = []; - const category = fileName.replace(".json", ""); + if (!tokens || typeof tokens !== "object") return results; + + const category = String(fileName).replace(".json", ""); function traverse(obj, path = "") { + if (!obj || typeof obj !== "object") return; for (const [key, value] of Object.entries(obj)) { const currentPath = path ? `${path}.${key}` : key; - if (value && typeof value === "object") { + if (value != null && typeof value === "object") { if (value.$value !== undefined || value.value !== undefined) { - // This is a token - const tokenType = value.$type || value.type || "unknown"; + const tokenType = value.$type ?? value.type ?? "unknown"; - // Apply type filter - if (type && tokenType !== type) { + if (type != null && type !== "" && tokenType !== type) { continue; } - // Apply query filter - if (query && !matchesQuery(currentPath, value, query)) { + if ( + query != null && + query !== "" && + !matchesQuery(currentPath, value, query) + ) { continue; } @@ -520,17 +578,16 @@ function processTokens(tokens, fileName, query, type) { path: currentPath, category, type: tokenType, - value: value.$value || value.value, - description: value.$description || value.description, - extensions: value.$extensions || value.extensions, + value: value.$value ?? value.value, + description: value.$description ?? value.description, + extensions: value.$extensions ?? value.extensions, uuid: value.uuid, - private: value.private || false, - deprecated: value.deprecated || false, + private: value.private === true, + deprecated: value.deprecated === true, deprecated_comment: value.deprecated_comment, renamed: value.renamed, }); } else { - // Recurse into nested objects traverse(value, currentPath); } } @@ -548,17 +605,22 @@ function processTokens(tokens, fileName, query, type) { * @returns {Object|null} Token object or null if not found */ function findTokenByPath(tokens, path) { - const parts = path.split("."); + if (!tokens || typeof tokens !== "object" || !path) return null; + const parts = String(path).split("."); let current = tokens; for (const part of parts) { - if (current[part] === undefined) { + if ( + current == null || + typeof current !== "object" || + current[part] === undefined + ) { return null; } current = current[part]; } - return current; + return current != null && typeof current === "object" ? current : null; } /** @@ -569,21 +631,18 @@ function findTokenByPath(tokens, path) { * @returns {boolean} Whether the token matches */ function matchesQuery(path, token, query) { - const searchText = query.toLowerCase(); + const searchText = String(query).toLowerCase(); - // Search in path - if (path.toLowerCase().includes(searchText)) { + if (String(path).toLowerCase().includes(searchText)) { return true; } - // Search in description - const description = token.$description || token.description || ""; - if (description.toLowerCase().includes(searchText)) { + const description = token?.$description ?? token?.description ?? ""; + if (String(description).toLowerCase().includes(searchText)) { return true; } - // Search in value (for string values) - const value = token.$value || token.value || ""; + const value = token?.$value ?? token?.value ?? ""; if (typeof value === "string" && value.toLowerCase().includes(searchText)) { return true; } diff --git a/tools/spectrum-design-data-mcp/src/tools/workflows.js b/tools/spectrum-design-data-mcp/src/tools/workflows.js new file mode 100644 index 00000000..b2628065 --- /dev/null +++ b/tools/spectrum-design-data-mcp/src/tools/workflows.js @@ -0,0 +1,266 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { getTokenData } from "../data/tokens.js"; +import { getSchemaData } from "../data/schemas.js"; +import { RESULT_LIMITS } from "../constants.js"; +import { + validateComponentName, + validatePropsObject, + validateStringParam, +} from "../utils/validation.js"; +import { + buildRecommendedProps, + validateComponentConfig, + validatePropsWithImprovements, +} from "../utils/component-helpers.js"; +import { + findComponentTokens, + findSemanticColorsByIntent, + findSemanticColorsByVariant, + findTokensByUseCase, + groupTokensByCategory, +} from "../utils/token-helpers.js"; + +/** + * Create workflow-oriented MCP tools that orchestrate multiple operations + * @returns {Array} Array of workflow tools + */ +export function createWorkflowTools() { + return [ + { + name: "build-component-config", + description: + "Generate a complete component configuration with recommended tokens and props. This tool orchestrates multiple MCP tools to provide a ready-to-use component configuration.", + inputSchema: { + type: "object", + properties: { + component: { + type: "string", + description: + 'Component name (e.g., "action-button", "text-field", "card")', + required: true, + }, + variant: { + type: "string", + description: + 'Component variant (e.g., "accent", "primary", "secondary")', + }, + intent: { + type: "string", + description: + 'Design intent (e.g., "primary", "secondary", "accent", "negative", "positive")', + }, + useCase: { + type: "string", + description: + 'Use case description (e.g., "primary action button", "error input field")', + }, + includeTokens: { + type: "boolean", + description: + "Include recommended design tokens in the configuration (default: true)", + default: true, + }, + }, + required: ["component"], + }, + handler: async (args) => { + const rawComponent = args?.component; + const variant = validateStringParam(args?.variant, "variant"); + const intent = validateStringParam(args?.intent, "intent"); + const useCase = validateStringParam(args?.useCase, "useCase"); + const includeTokens = args?.includeTokens !== false; + + const component = validateComponentName(rawComponent); + + const schemaData = await getSchemaData(); + const tokenData = await getTokenData(); + + const fileName = `${component}.json`; + const schema = + schemaData?.components != null + ? schemaData.components[fileName] + : undefined; + + if (!schema || typeof schema !== "object") { + throw new Error( + `Component not found: ${component}. Use list-components to see available components.`, + ); + } + + const { recommendedProps, schemaProperties } = buildRecommendedProps( + schema, + variant, + ); + + const config = { + component, + schema: { + title: schema.title, + description: schema.description, + properties: schemaProperties, + }, + recommendedProps, + tokens: includeTokens ? {} : undefined, + validation: {}, + }; + + if (includeTokens && tokenData && typeof tokenData === "object") { + const componentTokens = findComponentTokens(tokenData, component); + if (componentTokens.length > 0) { + config.tokens.componentTokens = + groupTokensByCategory(componentTokens); + } + + if (intent) { + const semanticColors = + tokenData["semantic-color-palette.json"] ?? {}; + const recommendations = findSemanticColorsByIntent( + semanticColors, + intent, + RESULT_LIMITS.MAX_COLOR_RECOMMENDATIONS, + ); + if (recommendations.length > 0) { + config.tokens.colors = recommendations; + } + } + + if (useCase) { + const useCaseTokens = findTokensByUseCase( + tokenData, + useCase, + RESULT_LIMITS.MAX_USE_CASE_TOKENS, + ); + if (useCaseTokens.length > 0) { + config.tokens.useCaseTokens = useCaseTokens; + } + } + } + + const validationResult = validateComponentConfig( + config.recommendedProps, + schema, + ); + config.validation = { + valid: validationResult.valid, + errors: validationResult.errors, + warnings: validationResult.warnings, + }; + + return config; + }, + }, + { + name: "suggest-component-improvements", + description: + "Analyze an existing component configuration and suggest improvements including token recommendations, validation fixes, and best practices.", + inputSchema: { + type: "object", + properties: { + component: { + type: "string", + description: "Component name to analyze", + required: true, + }, + props: { + type: "object", + description: "Current component properties to analyze", + required: true, + }, + includeTokenSuggestions: { + type: "boolean", + description: + "Include token recommendations for styling (default: true)", + default: true, + }, + }, + required: ["component", "props"], + }, + handler: async (args) => { + const rawComponent = args?.component; + const props = validatePropsObject(args?.props); + const includeTokenSuggestions = args?.includeTokenSuggestions !== false; + + const component = validateComponentName(rawComponent); + + const schemaData = await getSchemaData(); + const tokenData = await getTokenData(); + + const fileName = `${component}.json`; + const schema = + schemaData?.components != null + ? schemaData.components[fileName] + : undefined; + + if (!schema || typeof schema !== "object") { + throw new Error( + `Component not found: ${component}. ` + + "Use list-components to see available components. " + + "Or use build-component-config to generate a complete configuration from scratch.", + ); + } + + const validationResult = validatePropsWithImprovements(props, schema); + + const suggestions = { + component, + currentProps: props, + validation: { + valid: validationResult.valid, + errors: validationResult.errors, + warnings: validationResult.warnings, + }, + improvements: validationResult.improvements, + tokenRecommendations: includeTokenSuggestions ? {} : undefined, + bestPractices: [ + "Use semantic tokens (semantic-color-palette) over raw palette tokens", + "Check for deprecated tokens and use renamed alternatives", + "Validate all props against the component schema", + "Use get-component-options for a user-friendly view of available props", + ], + }; + + if ( + includeTokenSuggestions && + tokenData && + typeof tokenData === "object" + ) { + const componentTokens = findComponentTokens(tokenData, component, { + excludePrivate: true, + excludeDeprecated: true, + }); + if (componentTokens.length > 0) { + suggestions.tokenRecommendations.componentTokens = + groupTokensByCategory(componentTokens); + } + + const variant = props.variant; + if (variant != null && typeof variant === "string") { + const semanticColors = + tokenData["semantic-color-palette.json"] ?? {}; + const semanticTokens = findSemanticColorsByVariant( + semanticColors, + String(variant), + RESULT_LIMITS.MAX_SEMANTIC_COLORS, + ); + if (semanticTokens.length > 0) { + suggestions.tokenRecommendations.semanticColors = semanticTokens; + } + } + } + + return suggestions; + }, + }, + ]; +} diff --git a/tools/spectrum-design-data-mcp/src/utils/component-helpers.js b/tools/spectrum-design-data-mcp/src/utils/component-helpers.js new file mode 100644 index 00000000..1cc2ef40 --- /dev/null +++ b/tools/spectrum-design-data-mcp/src/utils/component-helpers.js @@ -0,0 +1,162 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Build recommended props from schema (defaults + variant when applicable) + * @param {Object} schema - Component schema + * @param {string} [variant] - Optional variant to apply to variant property + * @returns {{ recommendedProps: Record, schemaProperties: Record }} + */ +export function buildRecommendedProps(schema, variant) { + const recommendedProps = /** @type {Record} */ ({}); + const schemaProperties = + /** @type {Record} */ ({}); + + if (!schema?.properties || typeof schema.properties !== "object") { + return { recommendedProps, schemaProperties }; + } + + const requiredSet = new Set(schema.required || []); + + for (const [propName, propDef] of Object.entries(schema.properties)) { + if (!propDef || typeof propDef !== "object") continue; + + schemaProperties[propName] = { + type: propDef.type, + description: propDef.description, + required: requiredSet.has(propName), + }; + + if (propDef.default !== undefined) { + recommendedProps[propName] = propDef.default; + } + + if (propName === "variant" && variant) { + const enumValues = propDef.enum; + if (Array.isArray(enumValues) && enumValues.includes(variant)) { + recommendedProps[propName] = variant; + } + } + } + + return { recommendedProps, schemaProperties }; +} + +/** + * Validate that required props are present + * @param {Record} props - Current props + * @param {Object} schema - Component schema + * @returns {{ valid: boolean, errors: string[], warnings: string[] }} + */ +export function validateComponentConfig(props, schema) { + const errors = []; + const required = schema?.required || []; + const schemaProps = schema?.properties || {}; + + for (const requiredProp of required) { + if (!(requiredProp in props)) { + errors.push(`Missing required property: ${requiredProp}`); + } + } + + return { + valid: errors.length === 0, + errors, + warnings: [], + }; +} + +/** + * Validate props against schema and collect improvements for suggest-component-improvements + * @param {Record} props - User props + * @param {Object} schema - Component schema + * @returns {{ valid: boolean, errors: string[], warnings: string[], improvements: Array<{ type: string, property: string, message: string, suggestion: string }> }} + */ +export function validatePropsWithImprovements(props, schema) { + const errors = []; + const warnings = []; + const improvements = []; + const schemaProps = schema?.properties || {}; + const required = schema?.required || []; + + for (const requiredProp of required) { + if (!(requiredProp in props)) { + errors.push(`Missing required property: ${requiredProp}`); + const propSchema = schemaProps[requiredProp]; + improvements.push({ + type: "missing_required", + property: requiredProp, + message: `Add required property: ${requiredProp}`, + suggestion: + propSchema?.default != null + ? String(propSchema.default) + : "See schema", + }); + } + } + + for (const propName of Object.keys(props)) { + if (!schemaProps[propName]) { + warnings.push(`Unknown property: ${propName}`); + improvements.push({ + type: "unknown_property", + property: propName, + message: `Property "${propName}" is not defined in the schema`, + suggestion: "Remove or check spelling", + }); + } + } + + for (const [propName, propValue] of Object.entries(props)) { + const propSchema = schemaProps[propName]; + if (!propSchema) continue; + + if (propSchema.type) { + const expectedType = propSchema.type; + const actualType = Array.isArray(propValue) ? "array" : typeof propValue; + + if (expectedType !== actualType) { + errors.push( + `Property ${propName} should be ${expectedType}, got ${actualType}`, + ); + improvements.push({ + type: "type_mismatch", + property: propName, + message: `Type mismatch: expected ${expectedType}, got ${actualType}`, + suggestion: `Change to ${expectedType}`, + }); + } + } + + if ( + Array.isArray(propSchema.enum) && + !propSchema.enum.includes(propValue) + ) { + warnings.push( + `Property ${propName} value "${propValue}" is not in allowed enum values`, + ); + improvements.push({ + type: "invalid_enum", + property: propName, + message: `Invalid value: "${propValue}"`, + suggestion: `Use one of: ${propSchema.enum.join(", ")}`, + }); + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + improvements, + }; +} diff --git a/tools/spectrum-design-data-mcp/src/utils/token-helpers.js b/tools/spectrum-design-data-mcp/src/utils/token-helpers.js new file mode 100644 index 00000000..34f887e2 --- /dev/null +++ b/tools/spectrum-design-data-mcp/src/utils/token-helpers.js @@ -0,0 +1,193 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + INTENT_SEMANTIC_MAPPINGS, + VARIANT_MAPPINGS, +} from "../config/intent-mappings.js"; + +/** + * Find tokens that match a component name across all categories + * @param {Record>} tokenData - Token data by category + * @param {string} componentName - Component name to match + * @param {Object} [options] - Options + * @param {boolean} [options.excludePrivate=false] - Exclude private tokens + * @param {boolean} [options.excludeDeprecated=false] - Exclude deprecated tokens + * @returns {Array<{ name: string, category: string, value: unknown, description?: string }>} + */ +export function findComponentTokens(tokenData, componentName, options = {}) { + const { excludePrivate = false, excludeDeprecated = false } = options; + const componentTokens = []; + const componentLower = componentName.toLowerCase(); + + for (const [category, tokens] of Object.entries(tokenData)) { + if (!tokens || typeof tokens !== "object") continue; + + for (const [name, token] of Object.entries(tokens)) { + if (!token || typeof token !== "object") continue; + if (excludePrivate && token.private) continue; + if (excludeDeprecated && token.deprecated) continue; + if (!name.toLowerCase().includes(componentLower)) continue; + + componentTokens.push({ + name, + category, + value: token.value, + description: token.description, + }); + } + } + + return componentTokens; +} + +/** + * Check if a token name matches the given intent (including semantic mappings) + * @param {string} nameLower - Token name lowercased + * @param {string} intentLower - Intent lowercased + * @returns {boolean} + */ +export function tokenNameMatchesIntent(nameLower, intentLower) { + if (nameLower.includes(intentLower)) return true; + const mapping = INTENT_SEMANTIC_MAPPINGS[intentLower]; + if (mapping) { + return mapping.some((sub) => nameLower.includes(sub)); + } + return false; +} + +/** + * Find semantic color tokens matching an intent + * @param {Record} semanticColors - Semantic color palette tokens + * @param {string} intent - Design intent (e.g. primary, error, success) + * @param {number} limit - Max results to return + * @returns {Array<{ name: string, value: unknown, category: string, type: string }>} + */ +export function findSemanticColorsByIntent(semanticColors, intent, limit) { + if (!semanticColors || typeof semanticColors !== "object") return []; + const recommendations = []; + const intentLower = intent.toLowerCase(); + + for (const [name, token] of Object.entries(semanticColors)) { + if (!token || typeof token !== "object") continue; + const nameLower = name.toLowerCase(); + if (!tokenNameMatchesIntent(nameLower, intentLower)) continue; + + recommendations.push({ + name, + value: token.value, + category: "semantic-color-palette", + type: "semantic", + }); + } + + return recommendations.slice(0, limit); +} + +/** + * Check if a token name matches the given variant (using variant mappings) + * @param {string} nameLower - Token name lowercased + * @param {string} variantLower - Variant lowercased + * @returns {boolean} + */ +export function tokenNameMatchesVariant(nameLower, variantLower) { + if (nameLower.includes(variantLower)) return true; + const mapping = VARIANT_MAPPINGS[variantLower]; + if (mapping) { + return mapping.some((sub) => nameLower.includes(sub)); + } + return false; +} + +/** + * Find semantic color tokens matching a variant + * @param {Record} semanticColors - Semantic color palette tokens + * @param {string} variant - Variant name (e.g. accent, negative) + * @param {number} limit - Max results to return + * @returns {Array<{ name: string, value: unknown, category: string, type: string }>} + */ +export function findSemanticColorsByVariant(semanticColors, variant, limit) { + if (!semanticColors || typeof semanticColors !== "object") return []; + const semanticTokens = []; + const variantLower = variant.toLowerCase(); + + for (const [name, token] of Object.entries(semanticColors)) { + if (!token || typeof token !== "object") continue; + const nameLower = name.toLowerCase(); + if (!tokenNameMatchesVariant(nameLower, variantLower)) continue; + + semanticTokens.push({ + name, + value: token.value, + category: "semantic-color-palette", + type: "semantic", + }); + } + + return semanticTokens.slice(0, limit); +} + +/** + * Find tokens matching a use case string (name or description) + * @param {Record>} tokenData - Token data by category + * @param {string} useCase - Use case search string + * @param {number} limit - Max results + * @param {Object} [options] - Options + * @param {boolean} [options.excludePrivate=true] - Exclude private tokens + * @returns {Array<{ name: string, category: string, value: unknown, description?: string }>} + */ +export function findTokensByUseCase(tokenData, useCase, limit, options = {}) { + const { excludePrivate = true } = options; + const useCaseLower = useCase.toLowerCase(); + const useCaseTokens = []; + + for (const [category, tokens] of Object.entries(tokenData)) { + if (!tokens || typeof tokens !== "object") continue; + + for (const [name, token] of Object.entries(tokens)) { + if (!token || typeof token !== "object") continue; + if (excludePrivate && token.private) continue; + + const nameMatch = name.toLowerCase().includes(useCaseLower); + const descMatch = + token.description && + String(token.description).toLowerCase().includes(useCaseLower); + + if (nameMatch || descMatch) { + useCaseTokens.push({ + name, + category, + value: token.value, + description: token.description, + }); + } + } + } + + return useCaseTokens.slice(0, limit); +} + +/** + * Group an array of tokens by their category + * @param {Array<{ category: string, [key: string]: unknown }>} tokens - Tokens with category + * @returns {Record>} + */ +export function groupTokensByCategory(tokens) { + if (!Array.isArray(tokens)) return {}; + return tokens.reduce((acc, token) => { + const category = token?.category; + if (category == null) return acc; + if (!acc[category]) acc[category] = []; + acc[category].push(token); + return acc; + }, /** @type {Record>} */ ({})); +} diff --git a/tools/spectrum-design-data-mcp/src/utils/validation.js b/tools/spectrum-design-data-mcp/src/utils/validation.js new file mode 100644 index 00000000..dd266c83 --- /dev/null +++ b/tools/spectrum-design-data-mcp/src/utils/validation.js @@ -0,0 +1,84 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Validate component name is a non-empty string without path separators + * @param {string} component - Component name to validate + * @returns {string} Trimmed component name (lowercased for lookup) + * @throws {Error} If component is invalid + */ +export function validateComponentName(component) { + if (!component || typeof component !== "string") { + throw new Error( + "Component name must be a non-empty string. " + + "Example: 'action-button', 'text-field', or 'card'. " + + "Use list-components to see all available components.", + ); + } + if (component.includes("/") || component.includes("\\")) { + throw new Error( + "Component name cannot contain path separators (/ or \\). " + + "Use the component name only, e.g., 'action-button' instead of 'components/action-button'.", + ); + } + return component.trim(); +} + +/** + * Validate and clamp limit parameter + * @param {number|undefined} limit - Requested limit + * @param {number} defaultLimit - Default when limit is invalid + * @param {number} maxLimit - Maximum allowed value + * @returns {number} Valid limit + */ +export function validateLimit(limit, defaultLimit, maxLimit = 100) { + const parsedLimit = Number(limit); + if (Number.isNaN(parsedLimit) || parsedLimit < 1) { + return defaultLimit; + } + return Math.min(parsedLimit, maxLimit); +} + +/** + * Validate props is a plain object (not array or null) + * @param {unknown} props - Props to validate + * @returns {Record} The props object + * @throws {Error} If props is invalid + */ +export function validatePropsObject(props) { + if (!props || typeof props !== "object" || Array.isArray(props)) { + throw new Error( + "Props must be a valid object (not an array or null). " + + "Example: { variant: 'accent', size: 'm' }. " + + "Use get-component-schema to see available properties.", + ); + } + return /** @type {Record} */ (props); +} + +/** + * Validate optional string parameter + * @param {unknown} param - Parameter value + * @param {string} paramName - Name for error messages + * @returns {string|undefined} The param if valid, undefined if not provided + * @throws {Error} If param is provided but not a string + */ +export function validateStringParam(param, paramName) { + if (param !== undefined && param !== null && typeof param !== "string") { + throw new Error( + `${paramName} must be a string. ` + + `Received: ${typeof param}. ` + + "Check your input and try again.", + ); + } + return param !== undefined && param !== null ? String(param) : undefined; +} diff --git a/tools/spectrum-design-data-mcp/test/skills/agent-skills-integration.test.js b/tools/spectrum-design-data-mcp/test/skills/agent-skills-integration.test.js new file mode 100644 index 00000000..1dc3122f --- /dev/null +++ b/tools/spectrum-design-data-mcp/test/skills/agent-skills-integration.test.js @@ -0,0 +1,317 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; +import { createWorkflowTools } from "../../src/tools/workflows.js"; +import { createTokenTools } from "../../src/tools/tokens.js"; +import { createSchemaTools } from "../../src/tools/schemas.js"; + +const workflowTools = createWorkflowTools(); +const tokenTools = createTokenTools(); +const schemaTools = createSchemaTools(); + +function getTool(tools, name) { + return tools.find((t) => t.name === name); +} + +async function getComponentSchema(component) { + const tool = getTool(schemaTools, "get-component-schema"); + return await tool.handler({ component }); +} + +async function getComponentTokens(componentName) { + const tool = getTool(tokenTools, "get-component-tokens"); + return await tool.handler({ componentName }); +} + +async function findTokensByUseCase(useCase, componentType) { + const tool = getTool(tokenTools, "find-tokens-by-use-case"); + return await tool.handler({ useCase, componentType }); +} + +async function getDesignRecommendations(intent, state, context) { + const tool = getTool(tokenTools, "get-design-recommendations"); + return await tool.handler({ intent, state, context }); +} + +async function validateProps(component, props) { + const tool = getTool(schemaTools, "validate-component-props"); + return await tool.handler({ component, props }); +} + +async function getTokenDetails(tokenPath, category) { + const tool = getTool(tokenTools, "get-token-details"); + return await tool.handler({ tokenPath, category }); +} + +async function buildComponentConfig(args) { + const tool = getTool(workflowTools, "build-component-config"); + return await tool.handler(args); +} + +// --- Component Builder Workflows (from component-builder.md) --- + +test("Component Builder: action-button primary medium", async (t) => { + const schema = await getComponentSchema("action-button"); + t.truthy(schema.schema); + t.truthy(schema.schema.properties); + + const tokens = await getComponentTokens("action-button"); + t.truthy(tokens.tokensByCategory); + + const bgTokens = await findTokensByUseCase( + "button background", + "action-button", + ); + t.true(Array.isArray(bgTokens.recommendations)); + + const recommendations = await getDesignRecommendations( + "primary", + undefined, + "button", + ); + t.truthy(recommendations.recommendations.colors); + + const validation = await validateProps("action-button", { + variant: "accent", + size: "m", + }); + t.true(validation.valid); +}); + +test("Component Builder: text-field error state", async (t) => { + const schema = await getComponentSchema("text-field"); + t.truthy(schema.schema); + + const tokens = await getComponentTokens("text-field"); + t.truthy(tokens.tokensByCategory); + + const errorTokens = await findTokensByUseCase("error state", "input"); + t.true(Array.isArray(errorTokens.recommendations)); + + const recommendations = await getDesignRecommendations( + "negative", + undefined, + "input", + ); + t.truthy(recommendations.recommendations.colors); + t.true(recommendations.recommendations.colors.length >= 0); + + const validation = await validateProps("text-field", { + validationState: "invalid", + errorMessage: "Please enter a valid value", + }); + t.truthy(validation); +}); + +// --- Token Finder Workflows (from token-finder.md) --- + +test("Token Finder: primary button colors", async (t) => { + const recommendations = await getDesignRecommendations( + "primary", + undefined, + "button", + ); + t.truthy(recommendations.recommendations.colors); + t.true(Array.isArray(recommendations.recommendations.colors)); + + t.true( + Array.isArray(recommendations.recommendations.colors), + "should return colors array", + ); + + const bgTokens = await findTokensByUseCase("button background", "button"); + t.true(Array.isArray(bgTokens.recommendations)); + + if (recommendations.recommendations.colors.length > 0) { + const firstColor = recommendations.recommendations.colors[0]; + const details = await getTokenDetails(firstColor.name); + t.truthy(details.token); + } +}); + +test("Token Finder: form field spacing", async (t) => { + const spacingTokens = await findTokensByUseCase("spacing", "input"); + t.true(Array.isArray(spacingTokens.recommendations)); + + const componentTokens = await getComponentTokens("text-field"); + t.truthy(componentTokens.tokensByCategory); + + const recommendations = await getDesignRecommendations( + "informative", + undefined, + "spacing", + ); + t.truthy(recommendations.recommendations.layout); + t.true(Array.isArray(recommendations.recommendations.layout)); +}); + +test("Token Finder: error messaging tokens", async (t) => { + const recommendations = await getDesignRecommendations( + "negative", + undefined, + "text", + ); + t.truthy(recommendations.recommendations.colors); + t.true(Array.isArray(recommendations.recommendations.colors)); + + const errorTokens = await findTokensByUseCase("error state"); + t.true(Array.isArray(errorTokens.recommendations)); + + if (recommendations.recommendations.colors.length > 0) { + const details = await getTokenDetails( + recommendations.recommendations.colors[0].name, + ); + t.truthy(details.token); + } +}); + +// --- SKILL.md example validation --- + +test("SKILL.md example: Component Builder workflow", async (t) => { + const schema = await getComponentSchema("action-button"); + t.truthy(schema.schema); + + const tokens = await getComponentTokens("action-button"); + t.truthy(tokens.tokensByCategory); + + const useCaseTokens = await findTokensByUseCase( + "button background", + "action-button", + ); + t.true(Array.isArray(useCaseTokens.recommendations)); + + const recommendations = await getDesignRecommendations( + "primary", + undefined, + "button", + ); + t.truthy(recommendations.recommendations); + + const props = { variant: "accent", size: "m" }; + const validation = await validateProps("action-button", props); + t.true(validation.valid); +}); + +test("SKILL.md example: Token Finder workflow", async (t) => { + const recommendations = await getDesignRecommendations( + "primary", + undefined, + "button", + ); + t.truthy(recommendations.recommendations); + + const bgTokens = await findTokensByUseCase("button background", "button"); + t.true(Array.isArray(bgTokens.recommendations)); + + const categoriesTool = getTool(tokenTools, "get-token-categories"); + const categoriesResult = await categoriesTool.handler({}); + t.truthy(categoriesResult.categories); + t.true(categoriesResult.categories.length > 0); +}); + +// --- Workflow validation --- + +test("Workflow validation: Component Builder steps execute in sequence", async (t) => { + const executionLog = []; + + const schema = await getComponentSchema("action-button"); + executionLog.push("get-component-schema"); + t.truthy(schema.schema); + + const tokens = await getComponentTokens("action-button"); + executionLog.push("get-component-tokens"); + t.truthy(tokens.tokensByCategory); + + await findTokensByUseCase("button background", "action-button"); + executionLog.push("find-tokens-by-use-case"); + + await getDesignRecommendations("primary", undefined, "button"); + executionLog.push("get-design-recommendations"); + + await validateProps("action-button", { variant: "accent" }); + executionLog.push("validate-component-props"); + + t.deepEqual(executionLog, [ + "get-component-schema", + "get-component-tokens", + "find-tokens-by-use-case", + "get-design-recommendations", + "validate-component-props", + ]); +}); + +test("Workflow performance: button builder completes in reasonable time", async (t) => { + const start = Date.now(); + + await getComponentSchema("action-button"); + await getComponentTokens("action-button"); + await findTokensByUseCase("button background", "action-button"); + await getDesignRecommendations("primary", undefined, "button"); + await validateProps("action-button", { variant: "accent", size: "m" }); + + const duration = Date.now() - start; + t.true(duration < 10000, "Workflow should complete in under 10 seconds"); +}); + +// --- Error handling --- + +test("Error handling: invalid component name for get-component-schema", async (t) => { + const error = await t.throwsAsync(async () => { + await getComponentSchema("non-existent-component"); + }); + t.true(error.message.includes("not found")); +}); + +test("Error handling: invalid token path for get-token-details", async (t) => { + const error = await t.throwsAsync(async () => { + await getTokenDetails("invalid.token.path.that.does.not.exist"); + }); + t.true(error.message.includes("not found")); +}); + +test("Error handling: build-component-config with invalid component", async (t) => { + const error = await t.throwsAsync(async () => { + await buildComponentConfig({ component: "non-existent-component" }); + }); + t.true(error.message.includes("not found")); +}); + +test("Error handling: get-component-schema with empty component", async (t) => { + const error = await t.throwsAsync(async () => { + await getComponentSchema(""); + }); + t.truthy(error.message); +}); + +test("Error handling: find-tokens-by-use-case with missing useCase", async (t) => { + const tool = getTool(tokenTools, "find-tokens-by-use-case"); + const error = await t.throwsAsync(async () => { + await tool.handler({}); + }); + t.truthy(error.message); +}); + +test("Error handling: get-design-recommendations with missing intent", async (t) => { + const tool = getTool(tokenTools, "get-design-recommendations"); + const error = await t.throwsAsync(async () => { + await tool.handler({}); + }); + t.truthy(error.message); +}); + +test("Error handling: validate-component-props with invalid component", async (t) => { + const error = await t.throwsAsync(async () => { + await validateProps("non-existent-component", { size: "m" }); + }); + t.true(error.message.includes("not found")); +}); diff --git a/tools/spectrum-design-data-mcp/test/tools/implementation-map.test.js b/tools/spectrum-design-data-mcp/test/tools/implementation-map.test.js new file mode 100644 index 00000000..6646ba6a --- /dev/null +++ b/tools/spectrum-design-data-mcp/test/tools/implementation-map.test.js @@ -0,0 +1,177 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use it except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; +import { createImplementationMapTools } from "../../src/tools/implementation-map.js"; + +test("createImplementationMapTools returns array of tools", (t) => { + const tools = createImplementationMapTools(); + t.true(Array.isArray(tools)); + t.true(tools.length >= 3); +}); + +test("implementation map tools have required properties", (t) => { + const tools = createImplementationMapTools(); + + for (const tool of tools) { + t.is(typeof tool.name, "string"); + t.is(typeof tool.description, "string"); + t.is(typeof tool.inputSchema, "object"); + t.is(typeof tool.handler, "function"); + } +}); + +test("resolve-implementation tool exists", (t) => { + const tools = createImplementationMapTools(); + const resolve = tools.find((tool) => tool.name === "resolve-implementation"); + t.truthy(resolve); + t.true(resolve.inputSchema.required.includes("platform")); + t.true(resolve.inputSchema.required.includes("tokenName")); +}); + +test("resolve-implementation returns style macro for known token", async (t) => { + const tools = createImplementationMapTools(); + const resolve = tools.find((tool) => tool.name === "resolve-implementation"); + + const result = await resolve.handler({ + platform: "react-spectrum", + tokenName: "accent-background-color-default", + }); + + t.true(result.ok); + t.is(result.platform, "react-spectrum"); + t.is(result.tokenName, "accent-background-color-default"); + t.deepEqual(result.styleMacro, { + property: "backgroundColor", + value: "accent", + }); + t.true(result.usage.includes("backgroundColor")); + t.true(result.usage.includes("accent")); +}); + +test("resolve-implementation returns style macro for font-size token", async (t) => { + const tools = createImplementationMapTools(); + const resolve = tools.find((tool) => tool.name === "resolve-implementation"); + + const result = await resolve.handler({ + platform: "react-spectrum", + tokenName: "font-size-100", + }); + + t.true(result.ok); + t.deepEqual(result.styleMacro, { property: "fontSize", value: "ui" }); +}); + +test("resolve-implementation returns error for unknown token", async (t) => { + const tools = createImplementationMapTools(); + const resolve = tools.find((tool) => tool.name === "resolve-implementation"); + + const result = await resolve.handler({ + platform: "react-spectrum", + tokenName: "nonexistent-token-name", + }); + + t.false(result.ok); + t.truthy(result.message); +}); + +test("resolve-implementation returns error for missing params", async (t) => { + const tools = createImplementationMapTools(); + const resolve = tools.find((tool) => tool.name === "resolve-implementation"); + + const result = await resolve.handler({ platform: "react-spectrum" }); + t.false(result.ok); + t.truthy(result.error); +}); + +test("resolve-implementation returns error for unsupported platform", async (t) => { + const tools = createImplementationMapTools(); + const resolve = tools.find((tool) => tool.name === "resolve-implementation"); + + const result = await resolve.handler({ + platform: "ios", + tokenName: "accent-background-color-default", + }); + + t.false(result.ok); + t.true(result.error.includes("Unsupported platform")); +}); + +test("reverse-lookup-implementation tool exists", (t) => { + const tools = createImplementationMapTools(); + const reverse = tools.find( + (tool) => tool.name === "reverse-lookup-implementation", + ); + t.truthy(reverse); + t.true(reverse.inputSchema.required.includes("platform")); + t.true(reverse.inputSchema.required.includes("property")); + t.true(reverse.inputSchema.required.includes("value")); +}); + +test("reverse-lookup-implementation finds token for style macro", async (t) => { + const tools = createImplementationMapTools(); + const reverse = tools.find( + (tool) => tool.name === "reverse-lookup-implementation", + ); + + const result = await reverse.handler({ + platform: "react-spectrum", + property: "backgroundColor", + value: "accent", + }); + + t.true(result.ok); + t.is(result.property, "backgroundColor"); + t.is(result.value, "accent"); + t.true(result.tokenNames.includes("accent-background-color-default")); +}); + +test("reverse-lookup-implementation returns empty for unknown style value", async (t) => { + const tools = createImplementationMapTools(); + const reverse = tools.find( + (tool) => tool.name === "reverse-lookup-implementation", + ); + + const result = await reverse.handler({ + platform: "react-spectrum", + property: "backgroundColor", + value: "nonexistent-value", + }); + + t.true(result.ok); + t.is(result.tokenNames.length, 0); +}); + +test("list-implementation-mappings tool exists", (t) => { + const tools = createImplementationMapTools(); + const list = tools.find( + (tool) => tool.name === "list-implementation-mappings", + ); + t.truthy(list); + t.true(list.inputSchema.required.includes("platform")); +}); + +test("list-implementation-mappings returns token names for react-spectrum", async (t) => { + const tools = createImplementationMapTools(); + const list = tools.find( + (tool) => tool.name === "list-implementation-mappings", + ); + + const result = await list.handler({ platform: "react-spectrum" }); + + t.true(result.ok); + t.is(result.platform, "react-spectrum"); + t.true(Array.isArray(result.tokenNames)); + t.true(result.count > 0); + t.true(result.tokenNames.includes("accent-background-color-default")); + t.true(result.tokenNames.includes("font-size-100")); +}); diff --git a/tools/spectrum-design-data-mcp/test/tools/workflows.test.js b/tools/spectrum-design-data-mcp/test/tools/workflows.test.js new file mode 100644 index 00000000..c3104e9d --- /dev/null +++ b/tools/spectrum-design-data-mcp/test/tools/workflows.test.js @@ -0,0 +1,292 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; +import { createWorkflowTools } from "../../src/tools/workflows.js"; + +test("createWorkflowTools returns array of tools", (t) => { + const tools = createWorkflowTools(); + t.true(Array.isArray(tools)); + t.true(tools.length > 0); +}); + +test("workflow tools have required properties", (t) => { + const tools = createWorkflowTools(); + + for (const tool of tools) { + t.is(typeof tool.name, "string"); + t.is(typeof tool.description, "string"); + t.is(typeof tool.inputSchema, "object"); + t.is(typeof tool.handler, "function"); + } +}); + +test("build-component-config tool exists", (t) => { + const tools = createWorkflowTools(); + const buildTool = tools.find( + (tool) => tool.name === "build-component-config", + ); + + t.truthy(buildTool); + t.is(buildTool.name, "build-component-config"); + t.true(buildTool.description.includes("component configuration")); + t.true(buildTool.inputSchema.required.includes("component")); +}); + +test("suggest-component-improvements tool exists", (t) => { + const tools = createWorkflowTools(); + const suggestTool = tools.find( + (tool) => tool.name === "suggest-component-improvements", + ); + + t.truthy(suggestTool); + t.is(suggestTool.name, "suggest-component-improvements"); + t.true(suggestTool.description.includes("improvements")); + t.true(suggestTool.inputSchema.required.includes("component")); + t.true(suggestTool.inputSchema.required.includes("props")); +}); + +test("build-component-config handles valid component", async (t) => { + const tools = createWorkflowTools(); + const buildTool = tools.find( + (tool) => tool.name === "build-component-config", + ); + + const result = await buildTool.handler({ + component: "action-button", + includeTokens: false, + }); + + t.truthy(result); + t.is(result.component, "action-button"); + t.truthy(result.schema); + t.truthy(result.recommendedProps); + t.truthy(result.validation); +}); + +test("build-component-config throws error for invalid component", async (t) => { + const tools = createWorkflowTools(); + const buildTool = tools.find( + (tool) => tool.name === "build-component-config", + ); + + const error = await t.throwsAsync(async () => { + await buildTool.handler({ + component: "non-existent-component", + }); + }); + + t.true(error.message.includes("not found")); +}); + +test("build-component-config includes tokens when requested", async (t) => { + const tools = createWorkflowTools(); + const buildTool = tools.find( + (tool) => tool.name === "build-component-config", + ); + + const result = await buildTool.handler({ + component: "action-button", + includeTokens: true, + }); + + t.truthy(result); + t.truthy(result.tokens); +}); + +test("build-component-config excludes tokens when not requested", async (t) => { + const tools = createWorkflowTools(); + const buildTool = tools.find( + (tool) => tool.name === "build-component-config", + ); + + const result = await buildTool.handler({ + component: "action-button", + includeTokens: false, + }); + + t.truthy(result); + t.is(result.tokens, undefined); +}); + +test("build-component-config applies variant when provided", async (t) => { + const tools = createWorkflowTools(); + const buildTool = tools.find( + (tool) => tool.name === "build-component-config", + ); + + const result = await buildTool.handler({ + component: "action-button", + variant: "accent", + includeTokens: false, + }); + + t.truthy(result); + // Variant should be applied if it's a valid enum value + if (result.recommendedProps.variant) { + t.is(result.recommendedProps.variant, "accent"); + } +}); + +test("suggest-component-improvements handles valid component", async (t) => { + const tools = createWorkflowTools(); + const suggestTool = tools.find( + (tool) => tool.name === "suggest-component-improvements", + ); + + const result = await suggestTool.handler({ + component: "action-button", + props: { + variant: "accent", + }, + includeTokenSuggestions: false, + }); + + t.truthy(result); + t.is(result.component, "action-button"); + t.deepEqual(result.currentProps, { variant: "accent" }); + t.truthy(result.validation); + t.truthy(result.improvements); + t.truthy(result.bestPractices); +}); + +test("suggest-component-improvements throws error for invalid component", async (t) => { + const tools = createWorkflowTools(); + const suggestTool = tools.find( + (tool) => tool.name === "suggest-component-improvements", + ); + + const error = await t.throwsAsync(async () => { + await suggestTool.handler({ + component: "non-existent-component", + props: {}, + }); + }); + + t.true(error.message.includes("not found")); +}); + +test("suggest-component-improvements includes token suggestions when requested", async (t) => { + const tools = createWorkflowTools(); + const suggestTool = tools.find( + (tool) => tool.name === "suggest-component-improvements", + ); + + const result = await suggestTool.handler({ + component: "action-button", + props: { + variant: "accent", + }, + includeTokenSuggestions: true, + }); + + t.truthy(result); + t.truthy(result.tokenRecommendations); +}); + +test("suggest-component-improvements excludes token suggestions when not requested", async (t) => { + const tools = createWorkflowTools(); + const suggestTool = tools.find( + (tool) => tool.name === "suggest-component-improvements", + ); + + const result = await suggestTool.handler({ + component: "action-button", + props: { + variant: "accent", + }, + includeTokenSuggestions: false, + }); + + t.truthy(result); + t.is(result.tokenRecommendations, undefined); +}); + +test("suggest-component-improvements detects missing required props", async (t) => { + const tools = createWorkflowTools(); + const suggestTool = tools.find( + (tool) => tool.name === "suggest-component-improvements", + ); + + const result = await suggestTool.handler({ + component: "action-button", + props: {}, + includeTokenSuggestions: false, + }); + + t.truthy(result); + // If the component has required props, validation should catch missing ones + if (result.validation.errors.length > 0) { + t.true( + result.validation.errors.some((error) => + error.includes("Missing required"), + ), + ); + } +}); + +test("suggest-component-improvements detects unknown properties", async (t) => { + const tools = createWorkflowTools(); + const suggestTool = tools.find( + (tool) => tool.name === "suggest-component-improvements", + ); + + const result = await suggestTool.handler({ + component: "action-button", + props: { + unknownProperty: "value", + }, + includeTokenSuggestions: false, + }); + + t.truthy(result); + // Should detect unknown properties + if (result.validation.warnings.length > 0) { + t.true( + result.validation.warnings.some((warning) => + warning.includes("Unknown property"), + ), + ); + } +}); + +test("build-component-config throws for invalid component name", async (t) => { + const tools = createWorkflowTools(); + const buildTool = tools.find( + (tool) => tool.name === "build-component-config", + ); + + const error = await t.throwsAsync(async () => { + await buildTool.handler({ component: "" }); + }); + t.true(error.message.includes("non-empty string")); + + const error2 = await t.throwsAsync(async () => { + await buildTool.handler({ component: "foo/bar" }); + }); + t.true(error2.message.includes("path separators")); +}); + +test("suggest-component-improvements throws for invalid props", async (t) => { + const tools = createWorkflowTools(); + const suggestTool = tools.find( + (tool) => tool.name === "suggest-component-improvements", + ); + + const error = await t.throwsAsync(async () => { + await suggestTool.handler({ + component: "action-button", + props: "not-an-object", + }); + }); + t.true(error.message.includes("valid object")); +}); diff --git a/tools/spectrum-design-data-mcp/test/utils/component-helpers.test.js b/tools/spectrum-design-data-mcp/test/utils/component-helpers.test.js new file mode 100644 index 00000000..c886a38c --- /dev/null +++ b/tools/spectrum-design-data-mcp/test/utils/component-helpers.test.js @@ -0,0 +1,104 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; +import { + buildRecommendedProps, + validateComponentConfig, + validatePropsWithImprovements, +} from "../../src/utils/component-helpers.js"; + +test("buildRecommendedProps returns defaults from schema", (t) => { + const schema = { + properties: { + size: { type: "string", default: "m" }, + variant: { type: "string", enum: ["accent", "primary"] }, + }, + }; + const { recommendedProps, schemaProperties } = buildRecommendedProps(schema); + t.is(recommendedProps.size, "m"); + t.truthy(schemaProperties.size); +}); + +test("buildRecommendedProps applies variant when valid enum", (t) => { + const schema = { + properties: { + variant: { + type: "string", + enum: ["accent", "primary"], + default: "primary", + }, + }, + }; + const { recommendedProps } = buildRecommendedProps(schema, "accent"); + t.is(recommendedProps.variant, "accent"); +}); + +test("buildRecommendedProps ignores variant when not in enum", (t) => { + const schema = { + properties: { + variant: { + type: "string", + enum: ["accent", "primary"], + default: "primary", + }, + }, + }; + const { recommendedProps } = buildRecommendedProps(schema, "invalid"); + t.is(recommendedProps.variant, "primary"); +}); + +test("validateComponentConfig reports missing required", (t) => { + const schema = { required: ["size"] }; + const result = validateComponentConfig({}, schema); + t.false(result.valid); + t.true(result.errors.some((e) => e.includes("size"))); +}); + +test("validateComponentConfig valid when required present", (t) => { + const schema = { required: ["size"] }; + const result = validateComponentConfig({ size: "m" }, schema); + t.true(result.valid); +}); + +test("validatePropsWithImprovements returns improvements for missing required", (t) => { + const schema = { + required: ["size"], + properties: { size: { type: "string", default: "m" } }, + }; + const result = validatePropsWithImprovements({}, schema); + t.false(result.valid); + t.is(result.improvements.length, 1); + t.is(result.improvements[0].type, "missing_required"); +}); + +test("validatePropsWithImprovements returns improvements for unknown property", (t) => { + const schema = { properties: {} }; + const result = validatePropsWithImprovements({ unknown: 1 }, schema); + t.true(result.warnings.some((w) => w.includes("Unknown"))); + t.true(result.improvements.some((i) => i.type === "unknown_property")); +}); + +test("validatePropsWithImprovements returns improvements for type mismatch", (t) => { + const schema = { properties: { size: { type: "string" } } }; + const result = validatePropsWithImprovements({ size: 123 }, schema); + t.false(result.valid); + t.true(result.improvements.some((i) => i.type === "type_mismatch")); +}); + +test("validatePropsWithImprovements returns improvements for invalid enum", (t) => { + const schema = { + properties: { variant: { type: "string", enum: ["a", "b"] } }, + }; + const result = validatePropsWithImprovements({ variant: "c" }, schema); + t.true(result.improvements.some((i) => i.type === "invalid_enum")); +}); diff --git a/tools/spectrum-design-data-mcp/test/utils/token-helpers.test.js b/tools/spectrum-design-data-mcp/test/utils/token-helpers.test.js new file mode 100644 index 00000000..5340c25f --- /dev/null +++ b/tools/spectrum-design-data-mcp/test/utils/token-helpers.test.js @@ -0,0 +1,116 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; +import { + findComponentTokens, + findSemanticColorsByIntent, + findSemanticColorsByVariant, + findTokensByUseCase, + groupTokensByCategory, + tokenNameMatchesIntent, + tokenNameMatchesVariant, +} from "../../src/utils/token-helpers.js"; + +test("findComponentTokens returns tokens matching component name", (t) => { + const tokenData = { + "color-component.json": { + "action-button-background": { value: "#fff", description: "Bg" }, + "other-token": { value: "#000" }, + }, + }; + const result = findComponentTokens(tokenData, "action-button"); + t.is(result.length, 1); + t.is(result[0].name, "action-button-background"); +}); + +test("findComponentTokens excludes private when option set", (t) => { + const tokenData = { + "color.json": { + "button-secret": { value: "#fff", private: true }, + "button-public": { value: "#000" }, + }, + }; + const result = findComponentTokens(tokenData, "button", { + excludePrivate: true, + }); + t.is(result.length, 1); + t.is(result[0].name, "button-public"); +}); + +test("findComponentTokens skips invalid token data", (t) => { + const tokenData = { + "a.json": null, + "b.json": { x: { value: 1 } }, + }; + const result = findComponentTokens(tokenData, "x"); + t.is(result.length, 1); +}); + +test("tokenNameMatchesIntent uses direct match", (t) => { + t.true(tokenNameMatchesIntent("primary-background", "primary")); +}); + +test("tokenNameMatchesIntent uses semantic mapping for error->negative", (t) => { + t.true(tokenNameMatchesIntent("negative-color", "error")); +}); + +test("findSemanticColorsByIntent returns limited results", (t) => { + const semantic = { + "primary-100": { value: "#111" }, + "primary-200": { value: "#222" }, + "primary-300": { value: "#333" }, + "primary-400": { value: "#444" }, + "primary-500": { value: "#555" }, + }; + const result = findSemanticColorsByIntent(semantic, "primary", 2); + t.is(result.length, 2); +}); + +test("findSemanticColorsByVariant uses variant mappings", (t) => { + const semantic = { + "accent-fill": { value: "#blue" }, + }; + const result = findSemanticColorsByVariant(semantic, "accent", 5); + t.is(result.length, 1); + t.is(result[0].name, "accent-fill"); +}); + +test("tokenNameMatchesVariant uses mapping", (t) => { + t.true(tokenNameMatchesVariant("accent-fill", "accent")); +}); + +test("findTokensByUseCase matches name and description", (t) => { + const tokenData = { + "color.json": { + "button-bg": { value: "#fff", description: "Button background color" }, + }, + }; + const result = findTokensByUseCase(tokenData, "button", 10); + t.is(result.length, 1); +}); + +test("groupTokensByCategory groups by category", (t) => { + const tokens = [ + { category: "a", name: "1" }, + { category: "a", name: "2" }, + { category: "b", name: "3" }, + ]; + const grouped = groupTokensByCategory(tokens); + t.deepEqual(Object.keys(grouped).sort(), ["a", "b"]); + t.is(grouped.a.length, 2); + t.is(grouped.b.length, 1); +}); + +test("groupTokensByCategory returns empty object for non-array", (t) => { + t.deepEqual(groupTokensByCategory(null), {}); +}); diff --git a/tools/spectrum-design-data-mcp/test/utils/validation.test.js b/tools/spectrum-design-data-mcp/test/utils/validation.test.js new file mode 100644 index 00000000..6d5ddd45 --- /dev/null +++ b/tools/spectrum-design-data-mcp/test/utils/validation.test.js @@ -0,0 +1,91 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import test from "ava"; +import { + validateComponentName, + validateLimit, + validatePropsObject, + validateStringParam, +} from "../../src/utils/validation.js"; + +test("validateComponentName accepts non-empty string and trims", (t) => { + t.is(validateComponentName("action-button"), "action-button"); + t.is(validateComponentName(" text-field "), "text-field"); +}); + +test("validateComponentName throws for empty string", (t) => { + const err = t.throws(() => validateComponentName("")); + t.true(err.message.includes("non-empty string")); +}); + +test("validateComponentName throws for non-string", (t) => { + t.throws(() => validateComponentName(null)); + t.throws(() => validateComponentName(undefined)); + t.throws(() => validateComponentName(123)); + t.throws(() => validateComponentName({})); +}); + +test("validateComponentName throws for path separators", (t) => { + const err1 = t.throws(() => validateComponentName("foo/bar")); + t.true(err1.message.includes("path separators")); + const err2 = t.throws(() => validateComponentName("foo\\bar")); + t.true(err2.message.includes("path separators")); +}); + +test("validateLimit returns default for invalid input", (t) => { + t.is(validateLimit(undefined, 50), 50); + t.is(validateLimit(NaN, 20), 20); + t.is(validateLimit(0, 50), 50); + t.is(validateLimit(-1, 50), 50); +}); + +test("validateLimit clamps to maxLimit", (t) => { + t.is(validateLimit(200, 50, 100), 100); + t.is(validateLimit(50, 50, 100), 50); +}); + +test("validateLimit accepts valid number", (t) => { + t.is(validateLimit(25, 50), 25); + t.is(validateLimit(1, 50), 1); +}); + +test("validatePropsObject accepts plain object", (t) => { + const obj = { a: 1 }; + t.is(validatePropsObject(obj), obj); +}); + +test("validatePropsObject throws for non-object", (t) => { + t.throws(() => validatePropsObject(null)); + t.throws(() => validatePropsObject(undefined)); + t.throws(() => validatePropsObject("string")); +}); + +test("validatePropsObject throws for array", (t) => { + const err = t.throws(() => validatePropsObject([])); + t.true(err.message.includes("valid object")); +}); + +test("validateStringParam returns undefined for missing param", (t) => { + t.is(validateStringParam(undefined, "foo"), undefined); + t.is(validateStringParam(null, "foo"), undefined); +}); + +test("validateStringParam returns string for valid param", (t) => { + t.is(validateStringParam("hello", "foo"), "hello"); +}); + +test("validateStringParam throws for non-string when provided", (t) => { + const err = t.throws(() => validateStringParam(123, "paramName")); + t.true(err.message.includes("paramName")); + t.true(err.message.includes("string")); +});