diff --git a/README.md b/README.md
index de39877..f449170 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
+

+
# HELiXiR
**Give AI agents full situational awareness of any web component library.**
@@ -13,6 +15,9 @@ Stop AI hallucinations. Ground every component suggestion in your actual Custom
[](https://nodejs.org)
[](https://github.com/bookedsolidtech/helixir/actions/workflows/build.yml)
[](https://github.com/bookedsolidtech/helixir/actions/workflows/test.yml)
+[](https://modelcontextprotocol.io)
+[](https://www.typescriptlang.org)
+[](https://www.npmjs.com/package/helixir)
[Quick Start](#quick-start) · [Why HELiXiR](#why-helixir) · [Tools Reference](#tools-reference) · [Configuration](#configuration) · [AI Tool Configs](#ai-tool-configs)
diff --git a/assets/social-card.png b/assets/social-card.png
new file mode 100644
index 0000000..8e9bdcf
Binary files /dev/null and b/assets/social-card.png differ
diff --git a/assets/social-card.webp b/assets/social-card.webp
new file mode 100644
index 0000000..8b8a772
Binary files /dev/null and b/assets/social-card.webp differ
diff --git a/build b/build
new file mode 120000
index 0000000..5df8d8b
--- /dev/null
+++ b/build
@@ -0,0 +1 @@
+/Volumes/Development/booked/helixir/build
\ No newline at end of file
diff --git a/node_modules b/node_modules
new file mode 120000
index 0000000..07c009a
--- /dev/null
+++ b/node_modules
@@ -0,0 +1 @@
+/Volumes/Development/booked/helixir/node_modules
\ No newline at end of file
diff --git a/packages/core/package.json b/packages/core/package.json
index 11856c5..c0c49ba 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -16,7 +16,7 @@
},
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.26.0",
+ "@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.22.0"
},
"peerDependencies": {
diff --git a/packages/core/src/handlers/theme.ts b/packages/core/src/handlers/theme.ts
index d10eaf8..55a6e94 100644
--- a/packages/core/src/handlers/theme.ts
+++ b/packages/core/src/handlers/theme.ts
@@ -145,7 +145,7 @@ function lightPlaceholder(tokenName: string, category: string): string {
return '200ms';
default:
- return '/* TODO: set value */';
+ return `var(${tokenName})`;
}
}
diff --git a/packages/core/src/tools/library.ts b/packages/core/src/tools/library.ts
index 10eb77b..7e4274a 100644
--- a/packages/core/src/tools/library.ts
+++ b/packages/core/src/tools/library.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { readFileSync } from 'fs';
+import { readFile } from 'fs/promises';
import { resolve, sep } from 'path';
import type { McpWcConfig } from '../config.js';
@@ -124,7 +124,7 @@ export async function handleLibraryCall(
}
let raw: string;
try {
- raw = readFileSync(absPath, 'utf-8');
+ raw = await readFile(absPath, 'utf-8');
} catch {
return createErrorResponse(`CEM file not found at ${absPath}`);
}
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 839f973..5ce658c 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -46,7 +46,7 @@
"homepage": "https://github.com/bookedsolidtech/helixir/tree/main/packages/mcp#readme",
"peerDependencies": {
"helixir": ">=0.5.0",
- "@modelcontextprotocol/sdk": "^1.26.0",
+ "@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.22.0"
},
"devDependencies": {
diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore
new file mode 100644
index 0000000..aa8e76e
--- /dev/null
+++ b/packages/vscode/.vscodeignore
@@ -0,0 +1,30 @@
+# Source files — not needed in the packaged extension
+src/
+tsconfig.json
+esbuild.config.mjs
+
+# Development dependencies and lock files
+node_modules/
+.pnpm-store/
+pnpm-lock.yaml
+package-lock.json
+
+# Test artefacts
+coverage/
+*.test.ts
+*.spec.ts
+
+# Build intermediates (keep dist/)
+*.map
+
+# Editor and OS artefacts
+.vscode/
+.DS_Store
+*.log
+
+# Root-level workspace files that should not be bundled
+../../node_modules/
+../../src/
+../../build/
+../../packages/
+../../.github/
diff --git a/packages/vscode/README.md b/packages/vscode/README.md
new file mode 100644
index 0000000..689b2fb
--- /dev/null
+++ b/packages/vscode/README.md
@@ -0,0 +1,84 @@
+# Helixir — VS Code Extension
+
+**AI-powered web component intelligence for VS Code.**
+
+Helixir gives AI assistants full situational awareness of any web component library by wiring the [helixir MCP server](https://github.com/bookedsolidtech/helixir) directly into VS Code's MCP layer.
+
+## Features
+
+- **MCP server auto-registration** — the helixir MCP server starts automatically with VS Code, no manual configuration required
+- **30+ MCP tools** — component discovery, health scoring, breaking-change detection, TypeScript diagnostics, design token lookup, and more
+- **Zero hallucinations** — every AI component suggestion is grounded in your actual `custom-elements.json`
+- **Framework-agnostic** — works with Lit, Stencil, FAST, Spectrum, Shoelace, or any library that produces a Custom Elements Manifest
+
+## Requirements
+
+- VS Code **≥ 1.99.0**
+- A component library with a `custom-elements.json` (Custom Elements Manifest)
+- Node.js **≥ 20** on `PATH`
+
+## Getting Started
+
+1. Install the extension from the VS Code Marketplace
+2. Open your component library folder in VS Code
+3. The Helixir MCP server will register automatically with AI assistants that support MCP (e.g., GitHub Copilot, Claude)
+
+### Optional: Configure the Config Path
+
+If your `mcpwc.config.json` is not at the workspace root, set the path via VS Code settings:
+
+```json
+// .vscode/settings.json
+{
+ "helixir.configPath": "packages/web-components/mcpwc.config.json"
+}
+```
+
+The path can be relative to the workspace root or absolute.
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `Helixir: Run Health Check` | Guides you to run a health check via your AI assistant |
+
+## Extension Settings
+
+| Setting | Type | Default | Description |
+|---------|------|---------|-------------|
+| `helixir.configPath` | `string` | `""` | Path to `mcpwc.config.json`. Empty = workspace root. |
+
+## How It Works
+
+When the extension activates, it registers a **MCP server definition provider** (`helixir`) with VS Code's language model API (`vscode.lm`). VS Code spawns the bundled helixir MCP server (`dist/mcp-server.js`) as a child process over stdio.
+
+The server reads your `custom-elements.json` and exposes 30+ tools that AI models can call to look up component APIs, run health scans, generate type declarations, and more.
+
+## Configuration Reference
+
+The helixir server is configured via environment variables passed by the extension:
+
+| Variable | Description |
+|----------|-------------|
+| `MCP_WC_PROJECT_ROOT` | Set to your workspace folder automatically |
+| `MCP_WC_CONFIG_PATH` | Set when `helixir.configPath` is configured |
+
+Additional configuration (token path, component prefix, health history dir) belongs in `mcpwc.config.json`. See the [helixir documentation](https://github.com/bookedsolidtech/helixir) for the full config reference.
+
+## Troubleshooting
+
+**MCP server not appearing in AI assistant tools**
+- Verify VS Code ≥ 1.99.0 is installed
+- Confirm your workspace contains a `custom-elements.json`
+- Check the Output panel → Helixir for error messages
+
+**"No workspace folder" error from Run Health Check**
+- Open a folder (not just a file) in VS Code — the extension uses the workspace folder as the project root
+
+**Server starts but returns no components**
+- Ensure `custom-elements.json` exists at the workspace root or configure `helixir.configPath`
+- Regenerate the manifest: `npm run analyze:cem` (or your CEM generation script)
+
+## License
+
+MIT — see [LICENSE](../../LICENSE)
diff --git a/packages/vscode/esbuild.config.mjs b/packages/vscode/esbuild.config.mjs
new file mode 100644
index 0000000..85e5030
--- /dev/null
+++ b/packages/vscode/esbuild.config.mjs
@@ -0,0 +1,69 @@
+/**
+ * esbuild configuration for the Helixir VS Code extension.
+ *
+ * Produces two bundles:
+ * dist/extension.js — VS Code extension host entry (CJS, externalizes 'vscode')
+ * dist/mcp-server.js — Helixir MCP server entry (ESM, bundles helixir)
+ */
+
+import * as esbuild from 'esbuild';
+
+const isProduction = process.argv.includes('--production');
+const isWatch = process.argv.includes('--watch');
+
+const sharedOptions = {
+ bundle: true,
+ sourcemap: !isProduction,
+ minify: isProduction,
+ logLevel: 'info',
+ platform: 'node',
+ target: 'node20',
+};
+
+/**
+ * Bundle 1: VS Code extension host entry
+ * - CommonJS (VS Code extension host requires CJS)
+ * - 'vscode' is externalized — provided by the VS Code runtime
+ */
+const extensionConfig = {
+ ...sharedOptions,
+ entryPoints: ['src/extension.ts'],
+ outfile: 'dist/extension.js',
+ format: 'cjs',
+ external: ['vscode'],
+};
+
+/**
+ * Bundle 2: Helixir MCP server
+ * - ESM format (helixir is an ES module)
+ * - Bundles helixir and its dependencies so the extension is self-contained
+ * - Spawned as a child process via stdio by the VS Code extension
+ */
+const mcpServerConfig = {
+ ...sharedOptions,
+ entryPoints: ['src/mcp-server-entry.ts'],
+ outfile: 'dist/mcp-server.js',
+ format: 'esm',
+ banner: {
+ js: '#!/usr/bin/env node\n// Helixir MCP Server — bundled by esbuild',
+ },
+};
+
+async function build() {
+ const extensionCtx = await esbuild.context(extensionConfig);
+ const mcpServerCtx = await esbuild.context(mcpServerConfig);
+
+ if (isWatch) {
+ await Promise.all([extensionCtx.watch(), mcpServerCtx.watch()]);
+ console.log('[helixir-vscode] Watching for changes...');
+ } else {
+ await Promise.all([extensionCtx.rebuild(), mcpServerCtx.rebuild()]);
+ await Promise.all([extensionCtx.dispose(), mcpServerCtx.dispose()]);
+ console.log('[helixir-vscode] Build complete.');
+ }
+}
+
+build().catch((err) => {
+ console.error('[helixir-vscode] Build failed:', err);
+ process.exit(1);
+});
diff --git a/packages/vscode/package.json b/packages/vscode/package.json
new file mode 100644
index 0000000..5d419f4
--- /dev/null
+++ b/packages/vscode/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "helixir-vscode",
+ "displayName": "Helixir",
+ "description": "AI-powered web component intelligence for VS Code — powered by helixir MCP",
+ "version": "0.1.0",
+ "publisher": "bookedsolidtech",
+ "private": true,
+ "engines": {
+ "vscode": "^1.99.0"
+ },
+ "categories": [
+ "Other",
+ "AI"
+ ],
+ "keywords": [
+ "mcp",
+ "web components",
+ "helixir",
+ "ai",
+ "custom elements"
+ ],
+ "activationEvents": [
+ "onStartupFinished"
+ ],
+ "main": "./dist/extension.js",
+ "contributes": {
+ "mcpServerDefinitionProviders": [
+ {
+ "id": "helixir",
+ "label": "Helixir"
+ }
+ ],
+ "commands": [
+ {
+ "command": "helixir.runHealthCheck",
+ "title": "Helixir: Run Health Check",
+ "category": "Helixir"
+ }
+ ],
+ "configuration": {
+ "title": "Helixir",
+ "properties": {
+ "helixir.configPath": {
+ "type": "string",
+ "default": "",
+ "description": "Path to mcpwc.config.json (relative to workspace root or absolute). Leave empty to use workspace root defaults."
+ }
+ }
+ }
+ },
+ "scripts": {
+ "vscode:prepublish": "node esbuild.config.mjs --production",
+ "build": "node esbuild.config.mjs",
+ "watch": "node esbuild.config.mjs --watch",
+ "package": "vsce package --no-dependencies",
+ "publish": "vsce publish --no-dependencies"
+ },
+ "dependencies": {
+ "helixir": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/vscode": "^1.99.0",
+ "@vscode/vsce": "^3.0.0",
+ "esbuild": "^0.25.0",
+ "ovsx": "^0.9.0"
+ }
+}
diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts
new file mode 100644
index 0000000..47192ac
--- /dev/null
+++ b/packages/vscode/src/extension.ts
@@ -0,0 +1,39 @@
+import * as vscode from 'vscode';
+import { registerMcpProvider } from './mcpProvider.js';
+
+/**
+ * Called when the extension is activated.
+ * Registers the Helixir MCP server definition provider and the
+ * "Helixir: Run Health Check" command.
+ */
+export function activate(context: vscode.ExtensionContext): void {
+ registerMcpProvider(context);
+
+ const healthCheckCommand = vscode.commands.registerCommand(
+ 'helixir.runHealthCheck',
+ async () => {
+ const workspaceFolders = vscode.workspace.workspaceFolders;
+ if (!workspaceFolders || workspaceFolders.length === 0) {
+ await vscode.window.showErrorMessage(
+ 'Helixir: No workspace folder is open. ' +
+ 'Open a component library folder to run a health check.'
+ );
+ return;
+ }
+
+ await vscode.window.showInformationMessage(
+ 'Helixir: MCP server is active. ' +
+ 'Ask your AI assistant to call score_all_components via the Helixir MCP server.'
+ );
+ }
+ );
+
+ context.subscriptions.push(healthCheckCommand);
+}
+
+/**
+ * Called when the extension is deactivated.
+ */
+export function deactivate(): void {
+ // Subscriptions are disposed automatically via context.subscriptions.
+}
diff --git a/packages/vscode/src/mcp-server-entry.ts b/packages/vscode/src/mcp-server-entry.ts
new file mode 100644
index 0000000..ef0068d
--- /dev/null
+++ b/packages/vscode/src/mcp-server-entry.ts
@@ -0,0 +1,16 @@
+/**
+ * Helixir MCP Server — entry point for the bundled server.
+ *
+ * This file is bundled by esbuild into dist/mcp-server.js (ESM format).
+ * It is spawned as a child process by the VS Code extension (mcpProvider.ts)
+ * using stdio transport.
+ *
+ * The helixir/mcp module exports a `main()` function that initialises and
+ * starts the MCP server, listening on stdin/stdout.
+ */
+import { main } from 'helixir/mcp';
+
+main().catch((err: unknown) => {
+ process.stderr.write(`[helixir-mcp] Fatal: ${String(err)}\n`);
+ process.exit(1);
+});
diff --git a/packages/vscode/src/mcpProvider.ts b/packages/vscode/src/mcpProvider.ts
new file mode 100644
index 0000000..0bf1e95
--- /dev/null
+++ b/packages/vscode/src/mcpProvider.ts
@@ -0,0 +1,68 @@
+import * as path from 'path';
+import * as vscode from 'vscode';
+
+/**
+ * Registers helixir as an MCP server definition provider with VS Code.
+ *
+ * The provider spawns dist/mcp-server.js (the bundled helixir MCP server)
+ * as a child process via stdio. VS Code passes it to connected AI models
+ * (e.g., GitHub Copilot, Claude) automatically.
+ *
+ * The server is configured with the workspace folder as MCP_WC_PROJECT_ROOT
+ * so helixir reads the correct custom-elements.json.
+ *
+ * Requires VS Code ≥ 1.99.0 (MCP server definition provider API).
+ */
+export function registerMcpProvider(context: vscode.ExtensionContext): void {
+ const provider = {
+ provideMcpServerDefinitions() {
+ const serverScriptPath = path.join(
+ context.extensionPath,
+ 'dist',
+ 'mcp-server.js'
+ );
+
+ const workspaceFolder =
+ vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd();
+
+ const configPath = vscode.workspace
+ .getConfiguration('helixir')
+ .get
('configPath', '');
+
+ const env: Record = {
+ MCP_WC_PROJECT_ROOT: workspaceFolder,
+ };
+
+ // If the user specified a custom config path, resolve it and pass it on.
+ if (configPath && configPath.trim() !== '') {
+ env['MCP_WC_CONFIG_PATH'] = path.isAbsolute(configPath)
+ ? configPath
+ : path.join(workspaceFolder, configPath);
+ }
+
+ return [
+ {
+ label: 'Helixir',
+ command: 'node',
+ args: [serverScriptPath],
+ env,
+ },
+ ];
+ },
+ };
+
+ // vscode.lm.registerMcpServerDefinitionProvider was introduced in VS Code 1.99.
+ // We guard the call so the extension degrades gracefully on older hosts.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const lm = vscode.lm as any;
+ if (typeof lm?.registerMcpServerDefinitionProvider === 'function') {
+ context.subscriptions.push(
+ lm.registerMcpServerDefinitionProvider('helixir', provider) as vscode.Disposable
+ );
+ } else {
+ console.warn(
+ '[helixir-vscode] vscode.lm.registerMcpServerDefinitionProvider is not available. ' +
+ 'Upgrade to VS Code ≥ 1.99.0 to enable MCP server support.'
+ );
+ }
+}
diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json
new file mode 100644
index 0000000..cb042b5
--- /dev/null
+++ b/packages/vscode/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "outDir": "dist",
+ "rootDir": "src",
+ "lib": ["ES2022"],
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/src/mcp/index.ts b/src/mcp/index.ts
index 37aea86..c3c49bd 100644
--- a/src/mcp/index.ts
+++ b/src/mcp/index.ts
@@ -92,6 +92,16 @@ import {
handleThemeCall,
isThemeTool,
} from '../../packages/core/src/tools/theme.js';
+import {
+ SCAFFOLD_TOOL_DEFINITIONS,
+ handleScaffoldCall,
+ isScaffoldTool,
+} from '../../packages/core/src/tools/scaffold.js';
+import {
+ EXTEND_TOOL_DEFINITIONS,
+ handleExtendCall,
+ isExtendTool,
+} from '../../packages/core/src/tools/extend.js';
import { createErrorResponse } from '../../packages/core/src/shared/mcp-helpers.js';
import type { MCPToolResult } from '../../packages/core/src/shared/mcp-helpers.js';
@@ -199,6 +209,8 @@ export async function main(): Promise {
...TYPEGENERATE_TOOL_DEFINITIONS,
...STYLING_TOOL_DEFINITIONS,
...THEME_TOOL_DEFINITIONS,
+ ...SCAFFOLD_TOOL_DEFINITIONS,
+ ...EXTEND_TOOL_DEFINITIONS,
...tsTools,
];
@@ -290,6 +302,20 @@ export async function main(): Promise {
);
return handleThemeCall(name, typedArgs, resolveCem(libraryId, cemCache));
}
+ if (isScaffoldTool(name)) {
+ if (cemCache === null || cemReloading)
+ return createErrorResponse(
+ 'CEM not yet loaded — server is still initializing. Please retry.',
+ );
+ return handleScaffoldCall(name, typedArgs, config, resolveCem(libraryId, cemCache));
+ }
+ if (isExtendTool(name)) {
+ if (cemCache === null || cemReloading)
+ return createErrorResponse(
+ 'CEM not yet loaded — server is still initializing. Please retry.',
+ );
+ return handleExtendCall(name, typedArgs, resolveCem(libraryId, cemCache));
+ }
if (isBenchmarkTool(name)) return handleBenchmarkCall(name, typedArgs, config);
if (isTypegenerateTool(name)) {
if (cemCache === null || cemReloading)
diff --git a/tests/handlers/theme.test.ts b/tests/handlers/theme.test.ts
index 38e0098..4e03a48 100644
--- a/tests/handlers/theme.test.ts
+++ b/tests/handlers/theme.test.ts
@@ -169,6 +169,18 @@ describe('createTheme', () => {
// Dark CSS should have dark values for bg
expect(result.darkModeCSS).toContain('#1a1a1a');
});
+
+ it('produces a var() fallback for tokens with unknown categories', () => {
+ // --my-custom-widget does not match any known category pattern,
+ // so it lands in "other" and hits the default case in lightPlaceholder.
+ const cem = makeCem([
+ { tagName: 'my-widget', cssProperties: [{ name: '--my-custom-widget' }] },
+ ]);
+ const result = createTheme(cem);
+ // The generated CSS should contain var(--my-custom-widget) instead of a TODO comment
+ expect(result.lightModeCSS).toContain('var(--my-custom-widget)');
+ expect(result.lightModeCSS).not.toContain('TODO');
+ });
});
// ─── applyThemeTokens ─────────────────────────────────────────────────────────
diff --git a/tests/tools/styling.test.ts b/tests/tools/styling.test.ts
new file mode 100644
index 0000000..6bcfed2
--- /dev/null
+++ b/tests/tools/styling.test.ts
@@ -0,0 +1,1260 @@
+/**
+ * Test suite for packages/core/src/tools/styling.ts
+ *
+ * Tests the handleStylingCall dispatcher and isStylingTool guard.
+ * All handler imports are mocked so no real CEM file reads or heavy
+ * computation happens during unit tests.
+ */
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { handleStylingCall, isStylingTool } from '../../packages/core/src/tools/styling.js';
+import type { Cem } from '../../packages/core/src/handlers/cem.js';
+import { MCPError, ErrorCategory } from '../../packages/core/src/shared/error-handling.js';
+
+// ---------------------------------------------------------------------------
+// Mock every handler that handleStylingCall delegates to
+// ---------------------------------------------------------------------------
+
+vi.mock('../../packages/core/src/handlers/cem.js', () => ({
+ parseCem: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/styling-diagnostics.js', () => ({
+ diagnoseStyling: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/shadow-dom-checker.js', () => ({
+ checkShadowDomUsage: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/html-usage-checker.js', () => ({
+ checkHtmlUsage: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/event-usage-checker.js', () => ({
+ checkEventUsage: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/quick-ref.js', () => ({
+ getComponentQuickRef: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/theme-detection.js', () => ({
+ detectThemeSupport: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/import-checker.js', () => ({
+ checkComponentImports: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/slot-children-checker.js', () => ({
+ checkSlotChildren: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/attribute-conflict-checker.js', () => ({
+ checkAttributeConflicts: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/a11y-usage-checker.js', () => ({
+ checkA11yUsage: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/css-var-checker.js', () => ({
+ checkCssVars: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/code-validator.js', () => ({
+ validateComponentCode: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/token-fallback-checker.js', () => ({
+ checkTokenFallbacks: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/composition-checker.js', () => ({
+ checkComposition: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/method-checker.js', () => ({
+ checkMethodCalls: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/theme-checker.js', () => ({
+ checkThemeCompatibility: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/recommend-checks.js', () => ({
+ recommendChecks: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/suggest-fix.js', () => ({
+ suggestFix: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/specificity-checker.js', () => ({
+ checkCssSpecificity: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/layout-checker.js', () => ({
+ checkLayoutPatterns: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/scope-checker.js', () => ({
+ checkCssScope: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/shorthand-checker.js', () => ({
+ checkCssShorthand: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/color-contrast-checker.js', () => ({
+ checkColorContrast: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/transition-checker.js', () => ({
+ checkTransitionAnimation: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/shadow-dom-js-checker.js', () => ({
+ checkShadowDomJs: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/css-api-resolver.js', () => ({
+ resolveCssApi: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/styling-preflight.js', () => ({
+ runStylingPreflight: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/css-file-validator.js', () => ({
+ validateCssFile: vi.fn(),
+}));
+
+vi.mock('../../packages/core/src/handlers/dark-mode-checker.js', () => ({
+ checkDarkModePatterns: vi.fn(),
+}));
+
+// ---------------------------------------------------------------------------
+// Import mocked handlers for assertion
+// ---------------------------------------------------------------------------
+
+import { parseCem } from '../../packages/core/src/handlers/cem.js';
+import { diagnoseStyling } from '../../packages/core/src/handlers/styling-diagnostics.js';
+import { checkShadowDomUsage } from '../../packages/core/src/handlers/shadow-dom-checker.js';
+import { checkHtmlUsage } from '../../packages/core/src/handlers/html-usage-checker.js';
+import { checkEventUsage } from '../../packages/core/src/handlers/event-usage-checker.js';
+import { getComponentQuickRef } from '../../packages/core/src/handlers/quick-ref.js';
+import { detectThemeSupport } from '../../packages/core/src/handlers/theme-detection.js';
+import { checkComponentImports } from '../../packages/core/src/handlers/import-checker.js';
+import { checkSlotChildren } from '../../packages/core/src/handlers/slot-children-checker.js';
+import { checkAttributeConflicts } from '../../packages/core/src/handlers/attribute-conflict-checker.js';
+import { checkA11yUsage } from '../../packages/core/src/handlers/a11y-usage-checker.js';
+import { checkCssVars } from '../../packages/core/src/handlers/css-var-checker.js';
+import { validateComponentCode } from '../../packages/core/src/handlers/code-validator.js';
+import { checkTokenFallbacks } from '../../packages/core/src/handlers/token-fallback-checker.js';
+import { checkComposition } from '../../packages/core/src/handlers/composition-checker.js';
+import { checkMethodCalls } from '../../packages/core/src/handlers/method-checker.js';
+import { checkThemeCompatibility } from '../../packages/core/src/handlers/theme-checker.js';
+import { recommendChecks } from '../../packages/core/src/handlers/recommend-checks.js';
+import { suggestFix } from '../../packages/core/src/handlers/suggest-fix.js';
+import { checkCssSpecificity } from '../../packages/core/src/handlers/specificity-checker.js';
+import { checkLayoutPatterns } from '../../packages/core/src/handlers/layout-checker.js';
+import { checkCssScope } from '../../packages/core/src/handlers/scope-checker.js';
+import { checkCssShorthand } from '../../packages/core/src/handlers/shorthand-checker.js';
+import { checkColorContrast } from '../../packages/core/src/handlers/color-contrast-checker.js';
+import { checkTransitionAnimation } from '../../packages/core/src/handlers/transition-checker.js';
+import { checkShadowDomJs } from '../../packages/core/src/handlers/shadow-dom-js-checker.js';
+import { resolveCssApi } from '../../packages/core/src/handlers/css-api-resolver.js';
+import { runStylingPreflight } from '../../packages/core/src/handlers/styling-preflight.js';
+import { validateCssFile } from '../../packages/core/src/handlers/css-file-validator.js';
+import { checkDarkModePatterns } from '../../packages/core/src/handlers/dark-mode-checker.js';
+
+// ---------------------------------------------------------------------------
+// Test helpers
+// ---------------------------------------------------------------------------
+
+/** Minimal CEM stub — enough for parseCem's type expectations */
+const FAKE_CEM: Cem = {
+ schemaVersion: '1.0.0',
+ modules: [],
+};
+
+/** Minimal component metadata returned by parseCem mocks */
+const FAKE_META = {
+ tagName: 'my-button',
+ name: 'MyButton',
+ description: 'A button component.',
+ members: [],
+ events: [],
+ slots: [],
+ cssProperties: [],
+ cssParts: [],
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.clearAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// isStylingTool
+// ---------------------------------------------------------------------------
+
+describe('isStylingTool', () => {
+ it('returns true for every defined styling tool name', () => {
+ const toolNames = [
+ 'diagnose_styling',
+ 'check_shadow_dom_usage',
+ 'check_html_usage',
+ 'check_event_usage',
+ 'get_component_quick_ref',
+ 'detect_theme_support',
+ 'check_component_imports',
+ 'check_slot_children',
+ 'check_attribute_conflicts',
+ 'check_a11y_usage',
+ 'check_css_vars',
+ 'validate_component_code',
+ 'check_token_fallbacks',
+ 'check_composition',
+ 'check_method_calls',
+ 'check_theme_compatibility',
+ 'recommend_checks',
+ 'suggest_fix',
+ 'check_css_specificity',
+ 'check_layout_patterns',
+ 'check_css_scope',
+ 'check_css_shorthand',
+ 'check_color_contrast',
+ 'check_transition_animation',
+ 'check_shadow_dom_js',
+ 'resolve_css_api',
+ 'styling_preflight',
+ 'validate_css_file',
+ 'check_dark_mode_patterns',
+ ];
+ for (const name of toolNames) {
+ expect(isStylingTool(name), `expected ${name} to be a styling tool`).toBe(true);
+ }
+ });
+
+ it('returns false for non-styling tool names', () => {
+ expect(isStylingTool('get_component')).toBe(false);
+ expect(isStylingTool('get_design_tokens')).toBe(false);
+ expect(isStylingTool('unknown_tool')).toBe(false);
+ expect(isStylingTool('')).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — unknown tool
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — unknown tool', () => {
+ it('returns an error for an unrecognised tool name', () => {
+ const result = handleStylingCall('nonexistent_tool', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown styling tool');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — diagnose_styling
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — diagnose_styling', () => {
+ it('calls parseCem and diagnoseStyling and returns their result', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(diagnoseStyling).mockReturnValue({ tokenPrefix: '--my-', approach: 'tokens' });
+
+ const result = handleStylingCall('diagnose_styling', { tagName: 'my-button' }, FAKE_CEM);
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(parseCem)).toHaveBeenCalledWith('my-button', FAKE_CEM);
+ expect(vi.mocked(diagnoseStyling)).toHaveBeenCalledWith(FAKE_META);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.tokenPrefix).toBe('--my-');
+ });
+
+ it('returns error when tagName is missing', () => {
+ const result = handleStylingCall('diagnose_styling', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+
+ it('propagates errors from parseCem as error responses', () => {
+ vi.mocked(parseCem).mockImplementation(() => {
+ throw new Error('Component not found in CEM');
+ });
+ const result = handleStylingCall('diagnose_styling', { tagName: 'unknown-tag' }, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_shadow_dom_usage
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_shadow_dom_usage', () => {
+ it('calls checkShadowDomUsage and returns the result', () => {
+ vi.mocked(checkShadowDomUsage).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_shadow_dom_usage',
+ { cssText: 'my-button .inner { color: red; }' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkShadowDomUsage)).toHaveBeenCalledWith(
+ 'my-button .inner { color: red; }',
+ undefined,
+ undefined,
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.issues).toEqual([]);
+ });
+
+ it('passes tagName to checkShadowDomUsage and attempts parseCem', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(checkShadowDomUsage).mockReturnValue({ issues: [] });
+
+ handleStylingCall(
+ 'check_shadow_dom_usage',
+ { cssText: 'my-button::part(base) { color: red; }', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(vi.mocked(parseCem)).toHaveBeenCalledWith('my-button', FAKE_CEM);
+ expect(vi.mocked(checkShadowDomUsage)).toHaveBeenCalledWith(
+ 'my-button::part(base) { color: red; }',
+ 'my-button',
+ FAKE_META,
+ );
+ });
+
+ it('still runs when tagName is provided but not in CEM (parseCem throws)', () => {
+ vi.mocked(parseCem).mockImplementation(() => {
+ throw new Error('not found');
+ });
+ vi.mocked(checkShadowDomUsage).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_shadow_dom_usage',
+ { cssText: 'x-button .foo {}', tagName: 'x-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ // meta should be undefined when parseCem throws
+ expect(vi.mocked(checkShadowDomUsage)).toHaveBeenCalledWith('x-button .foo {}', 'x-button', undefined);
+ });
+
+ it('returns error when cssText is missing', () => {
+ const result = handleStylingCall('check_shadow_dom_usage', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_html_usage
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_html_usage', () => {
+ it('calls checkHtmlUsage and returns the result', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(checkHtmlUsage).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_html_usage',
+ { htmlText: '', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkHtmlUsage)).toHaveBeenCalledWith(
+ '',
+ FAKE_META,
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_html_usage', { htmlText: '' }, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error when both args are missing', () => {
+ const result = handleStylingCall('check_html_usage', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_event_usage
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_event_usage', () => {
+ it('calls checkEventUsage with parsed args and returns result', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(checkEventUsage).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_event_usage',
+ { codeText: 'el.addEventListener("my-click", fn)', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkEventUsage)).toHaveBeenCalledWith(
+ 'el.addEventListener("my-click", fn)',
+ FAKE_META,
+ undefined,
+ );
+ });
+
+ it('passes the framework hint through to checkEventUsage', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(checkEventUsage).mockReturnValue({ issues: [] });
+
+ handleStylingCall(
+ 'check_event_usage',
+ { codeText: '', tagName: 'my-button', framework: 'react' },
+ FAKE_CEM,
+ );
+
+ expect(vi.mocked(checkEventUsage)).toHaveBeenCalledWith(expect.any(String), FAKE_META, 'react');
+ });
+
+ it('returns error for invalid framework enum value', () => {
+ const result = handleStylingCall(
+ 'check_event_usage',
+ { codeText: 'code', tagName: 'my-button', framework: 'svelte' },
+ FAKE_CEM,
+ );
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_event_usage', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — get_component_quick_ref
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — get_component_quick_ref', () => {
+ it('calls getComponentQuickRef and returns the result', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(getComponentQuickRef).mockReturnValue({ attributes: [], parts: [] });
+
+ const result = handleStylingCall(
+ 'get_component_quick_ref',
+ { tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(getComponentQuickRef)).toHaveBeenCalledWith(FAKE_META);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.attributes).toEqual([]);
+ });
+
+ it('returns error when tagName is missing', () => {
+ const result = handleStylingCall('get_component_quick_ref', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — detect_theme_support
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — detect_theme_support', () => {
+ it('calls detectThemeSupport with the CEM and returns result', () => {
+ vi.mocked(detectThemeSupport).mockReturnValue({ score: 80, categories: ['color', 'spacing'] });
+
+ const result = handleStylingCall('detect_theme_support', {}, FAKE_CEM);
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(detectThemeSupport)).toHaveBeenCalledWith(FAKE_CEM);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.score).toBe(80);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_component_imports
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_component_imports', () => {
+ it('calls checkComponentImports and returns the result', () => {
+ vi.mocked(checkComponentImports).mockReturnValue({ unknown: [], valid: ['my-button'] });
+
+ const result = handleStylingCall(
+ 'check_component_imports',
+ { codeText: '' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkComponentImports)).toHaveBeenCalledWith(
+ '',
+ FAKE_CEM,
+ );
+ });
+
+ it('returns error when codeText is missing', () => {
+ const result = handleStylingCall('check_component_imports', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_slot_children
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_slot_children', () => {
+ it('calls checkSlotChildren and returns the result', () => {
+ vi.mocked(checkSlotChildren).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_slot_children',
+ { htmlText: 'label', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkSlotChildren)).toHaveBeenCalledWith(
+ 'label',
+ 'my-button',
+ FAKE_CEM,
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_slot_children', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_attribute_conflicts
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_attribute_conflicts', () => {
+ it('calls checkAttributeConflicts and returns the result', () => {
+ vi.mocked(checkAttributeConflicts).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_attribute_conflicts',
+ { htmlText: 'Go', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkAttributeConflicts)).toHaveBeenCalledWith(
+ 'Go',
+ 'my-button',
+ FAKE_CEM,
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_attribute_conflicts', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_a11y_usage
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_a11y_usage', () => {
+ it('calls checkA11yUsage and returns the result', () => {
+ vi.mocked(checkA11yUsage).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_a11y_usage',
+ { htmlText: '', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkA11yUsage)).toHaveBeenCalledWith(
+ '',
+ 'my-button',
+ FAKE_CEM,
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_a11y_usage', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_css_vars
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_css_vars', () => {
+ it('calls checkCssVars and returns the result', () => {
+ vi.mocked(checkCssVars).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_css_vars',
+ { cssText: 'my-button { --my-color: red; }', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkCssVars)).toHaveBeenCalledWith(
+ 'my-button { --my-color: red; }',
+ 'my-button',
+ FAKE_CEM,
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_css_vars', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_theme_compatibility
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_theme_compatibility', () => {
+ it('calls checkThemeCompatibility and returns the result', () => {
+ vi.mocked(checkThemeCompatibility).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_theme_compatibility',
+ { cssText: 'my-button { color: #000; }' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkThemeCompatibility)).toHaveBeenCalledWith('my-button { color: #000; }');
+ });
+
+ it('returns error when cssText is missing', () => {
+ const result = handleStylingCall('check_theme_compatibility', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_method_calls
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_method_calls', () => {
+ it('calls checkMethodCalls and returns the result', () => {
+ vi.mocked(checkMethodCalls).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_method_calls',
+ { codeText: 'el.show()', tagName: 'my-dialog' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkMethodCalls)).toHaveBeenCalledWith('el.show()', 'my-dialog', FAKE_CEM);
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_method_calls', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_composition
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_composition', () => {
+ it('calls checkComposition and returns the result', () => {
+ vi.mocked(checkComposition).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_composition',
+ { htmlText: 'Tab 1' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkComposition)).toHaveBeenCalledWith(
+ 'Tab 1',
+ FAKE_CEM,
+ );
+ });
+
+ it('returns error when htmlText is missing', () => {
+ const result = handleStylingCall('check_composition', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — recommend_checks
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — recommend_checks', () => {
+ it('calls recommendChecks and returns the result', () => {
+ vi.mocked(recommendChecks).mockReturnValue({
+ tools: ['check_shadow_dom_usage', 'check_html_usage'],
+ });
+
+ const result = handleStylingCall(
+ 'recommend_checks',
+ { codeText: '' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(recommendChecks)).toHaveBeenCalledWith('');
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.tools).toContain('check_shadow_dom_usage');
+ });
+
+ it('returns error when codeText is missing', () => {
+ const result = handleStylingCall('recommend_checks', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — suggest_fix
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — suggest_fix', () => {
+ it('calls suggestFix and returns the result', () => {
+ vi.mocked(suggestFix).mockReturnValue({ fix: 'Use ::part(base) instead.' });
+
+ const result = handleStylingCall(
+ 'suggest_fix',
+ { type: 'shadow-dom', issue: 'descendant-piercing', original: 'my-button .inner {}' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(suggestFix)).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'shadow-dom', issue: 'descendant-piercing' }),
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.fix).toContain('::part(base)');
+ });
+
+ it('passes all optional fields to suggestFix', () => {
+ vi.mocked(suggestFix).mockReturnValue({ fix: 'ok' });
+
+ handleStylingCall(
+ 'suggest_fix',
+ {
+ type: 'method-call',
+ issue: 'property-as-method',
+ original: 'el.open()',
+ tagName: 'my-dialog',
+ memberName: 'open',
+ suggestedName: 'show',
+ },
+ FAKE_CEM,
+ );
+
+ expect(vi.mocked(suggestFix)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'method-call',
+ tagName: 'my-dialog',
+ memberName: 'open',
+ suggestedName: 'show',
+ }),
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('suggest_fix', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error for invalid type enum value', () => {
+ const result = handleStylingCall(
+ 'suggest_fix',
+ { type: 'not-a-type', issue: 'foo', original: 'bar' },
+ FAKE_CEM,
+ );
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_css_specificity
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_css_specificity', () => {
+ it('calls checkCssSpecificity without mode when mode is omitted', () => {
+ vi.mocked(checkCssSpecificity).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_css_specificity',
+ { code: '#app my-button { color: red !important; }' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkCssSpecificity)).toHaveBeenCalledWith(
+ '#app my-button { color: red !important; }',
+ undefined,
+ );
+ });
+
+ it('passes mode option to checkCssSpecificity', () => {
+ vi.mocked(checkCssSpecificity).mockReturnValue({ issues: [] });
+
+ handleStylingCall(
+ 'check_css_specificity',
+ { code: '', mode: 'html' },
+ FAKE_CEM,
+ );
+
+ expect(vi.mocked(checkCssSpecificity)).toHaveBeenCalledWith(expect.any(String), { mode: 'html' });
+ });
+
+ it('returns error when code is missing', () => {
+ const result = handleStylingCall('check_css_specificity', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_layout_patterns
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_layout_patterns', () => {
+ it('calls checkLayoutPatterns and returns the result', () => {
+ vi.mocked(checkLayoutPatterns).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_layout_patterns',
+ { cssText: 'my-button { display: flex; }' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkLayoutPatterns)).toHaveBeenCalledWith('my-button { display: flex; }');
+ });
+
+ it('returns error when cssText is missing', () => {
+ const result = handleStylingCall('check_layout_patterns', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_css_scope
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_css_scope', () => {
+ it('calls checkCssScope and returns the result', () => {
+ vi.mocked(checkCssScope).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_css_scope',
+ { cssText: ':root { --my-button-color: red; }', tagName: 'my-button', cem: FAKE_CEM },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkCssScope)).toHaveBeenCalledWith(
+ ':root { --my-button-color: red; }',
+ 'my-button',
+ FAKE_CEM,
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_css_scope', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_css_shorthand
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_css_shorthand', () => {
+ it('calls checkCssShorthand and returns the result', () => {
+ vi.mocked(checkCssShorthand).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_css_shorthand',
+ { cssText: 'my-button { border: 1px solid var(--my-color); }' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkCssShorthand)).toHaveBeenCalledWith(
+ 'my-button { border: 1px solid var(--my-color); }',
+ );
+ });
+
+ it('returns error when cssText is missing', () => {
+ const result = handleStylingCall('check_css_shorthand', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_color_contrast
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_color_contrast', () => {
+ it('calls checkColorContrast and returns the result', () => {
+ vi.mocked(checkColorContrast).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_color_contrast',
+ { cssText: 'my-button { color: #fff; background: #f0f0f0; }' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkColorContrast)).toHaveBeenCalledWith(
+ 'my-button { color: #fff; background: #f0f0f0; }',
+ );
+ });
+
+ it('returns error when cssText is missing', () => {
+ const result = handleStylingCall('check_color_contrast', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_transition_animation
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_transition_animation', () => {
+ it('calls checkTransitionAnimation and returns the result', () => {
+ vi.mocked(checkTransitionAnimation).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_transition_animation',
+ { cssText: 'my-button { transition: color 0.3s; }', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkTransitionAnimation)).toHaveBeenCalledWith(
+ 'my-button { transition: color 0.3s; }',
+ 'my-button',
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_transition_animation', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_shadow_dom_js
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_shadow_dom_js', () => {
+ it('calls checkShadowDomJs and returns the result', () => {
+ vi.mocked(checkShadowDomJs).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_shadow_dom_js',
+ { codeText: 'el.shadowRoot.querySelector(".foo")' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkShadowDomJs)).toHaveBeenCalledWith(
+ 'el.shadowRoot.querySelector(".foo")',
+ undefined,
+ );
+ });
+
+ it('passes optional tagName to checkShadowDomJs', () => {
+ vi.mocked(checkShadowDomJs).mockReturnValue({ issues: [] });
+
+ handleStylingCall(
+ 'check_shadow_dom_js',
+ { codeText: 'el.shadowRoot.querySelector(".foo")', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(vi.mocked(checkShadowDomJs)).toHaveBeenCalledWith(expect.any(String), 'my-button');
+ });
+
+ it('returns error when codeText is missing', () => {
+ const result = handleStylingCall('check_shadow_dom_js', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_token_fallbacks
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_token_fallbacks', () => {
+ it('calls checkTokenFallbacks and returns the result', () => {
+ vi.mocked(checkTokenFallbacks).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_token_fallbacks',
+ { cssText: 'my-button { color: var(--my-color); }', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkTokenFallbacks)).toHaveBeenCalledWith(
+ 'my-button { color: var(--my-color); }',
+ 'my-button',
+ FAKE_CEM,
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('check_token_fallbacks', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — validate_component_code
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — validate_component_code', () => {
+ it('calls validateComponentCode and returns the result', () => {
+ vi.mocked(validateComponentCode).mockReturnValue({ issues: [], passed: true });
+
+ const result = handleStylingCall(
+ 'validate_component_code',
+ { html: '', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(validateComponentCode)).toHaveBeenCalledWith(
+ expect.objectContaining({ html: '', tagName: 'my-button', cem: FAKE_CEM }),
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.passed).toBe(true);
+ });
+
+ it('passes optional css, code, and framework args', () => {
+ vi.mocked(validateComponentCode).mockReturnValue({ issues: [] });
+
+ handleStylingCall(
+ 'validate_component_code',
+ {
+ html: '',
+ tagName: 'my-button',
+ css: 'my-button { --color: red; }',
+ code: 'el.addEventListener("my-click", fn)',
+ framework: 'vue',
+ },
+ FAKE_CEM,
+ );
+
+ expect(vi.mocked(validateComponentCode)).toHaveBeenCalledWith(
+ expect.objectContaining({ css: 'my-button { --color: red; }', framework: 'vue' }),
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('validate_component_code', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — resolve_css_api
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — resolve_css_api', () => {
+ it('calls resolveCssApi and returns the result', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(resolveCssApi).mockReturnValue({ valid: [], invalid: [] });
+
+ const result = handleStylingCall(
+ 'resolve_css_api',
+ { cssText: 'my-button::part(base) {}', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(resolveCssApi)).toHaveBeenCalledWith(
+ 'my-button::part(base) {}',
+ FAKE_META,
+ undefined,
+ );
+ });
+
+ it('passes optional htmlText to resolveCssApi', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(resolveCssApi).mockReturnValue({ valid: [], invalid: [] });
+
+ handleStylingCall(
+ 'resolve_css_api',
+ {
+ cssText: 'my-button::part(base) {}',
+ tagName: 'my-button',
+ htmlText: '',
+ },
+ FAKE_CEM,
+ );
+
+ expect(vi.mocked(resolveCssApi)).toHaveBeenCalledWith(
+ expect.any(String),
+ FAKE_META,
+ '',
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('resolve_css_api', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — styling_preflight
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — styling_preflight', () => {
+ it('calls runStylingPreflight and returns the result', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(runStylingPreflight).mockReturnValue({ passed: true, issues: [] });
+
+ const result = handleStylingCall(
+ 'styling_preflight',
+ { cssText: 'my-button::part(base) { color: red; }', tagName: 'my-button' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(runStylingPreflight)).toHaveBeenCalledWith({
+ css: 'my-button::part(base) { color: red; }',
+ html: undefined,
+ meta: FAKE_META,
+ });
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.passed).toBe(true);
+ });
+
+ it('passes optional htmlText to runStylingPreflight', () => {
+ vi.mocked(parseCem).mockReturnValue(FAKE_META);
+ vi.mocked(runStylingPreflight).mockReturnValue({ passed: true, issues: [] });
+
+ handleStylingCall(
+ 'styling_preflight',
+ {
+ cssText: 'my-button::part(base) {}',
+ tagName: 'my-button',
+ htmlText: '',
+ },
+ FAKE_CEM,
+ );
+
+ expect(vi.mocked(runStylingPreflight)).toHaveBeenCalledWith(
+ expect.objectContaining({ html: '' }),
+ );
+ });
+
+ it('returns error when required args are missing', () => {
+ const result = handleStylingCall('styling_preflight', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — validate_css_file
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — validate_css_file', () => {
+ it('calls validateCssFile and returns the result', () => {
+ vi.mocked(validateCssFile).mockReturnValue({ components: [], globalIssues: [] });
+
+ const result = handleStylingCall(
+ 'validate_css_file',
+ { cssText: 'my-button { --color: red; }\nmy-card::part(base) {}' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(validateCssFile)).toHaveBeenCalledWith(
+ 'my-button { --color: red; }\nmy-card::part(base) {}',
+ FAKE_CEM,
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.globalIssues).toEqual([]);
+ });
+
+ it('returns error when cssText is missing', () => {
+ const result = handleStylingCall('validate_css_file', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — check_dark_mode_patterns
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — check_dark_mode_patterns', () => {
+ it('calls checkDarkModePatterns and returns the result', () => {
+ vi.mocked(checkDarkModePatterns).mockReturnValue({ issues: [] });
+
+ const result = handleStylingCall(
+ 'check_dark_mode_patterns',
+ { cssText: '.dark my-button { color: white; }' },
+ FAKE_CEM,
+ );
+
+ expect(result.isError).toBeFalsy();
+ expect(vi.mocked(checkDarkModePatterns)).toHaveBeenCalledWith('.dark my-button { color: white; }');
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.issues).toEqual([]);
+ });
+
+ it('returns error when cssText is missing', () => {
+ const result = handleStylingCall('check_dark_mode_patterns', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// handleStylingCall — error propagation via handleToolError
+// ---------------------------------------------------------------------------
+
+describe('handleStylingCall — error propagation', () => {
+ it('wraps MCPError category into the error message', () => {
+ vi.mocked(parseCem).mockImplementation(() => {
+ throw new MCPError('Component "bad-tag" not found in CEM.', ErrorCategory.NOT_FOUND);
+ });
+
+ const result = handleStylingCall('diagnose_styling', { tagName: 'bad-tag' }, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('NOT_FOUND');
+ expect(result.content[0].text).toContain('bad-tag');
+ });
+
+ it('wraps generic Error thrown by a handler', () => {
+ vi.mocked(checkShadowDomUsage).mockImplementation(() => {
+ throw new Error('unexpected handler failure');
+ });
+
+ const result = handleStylingCall(
+ 'check_shadow_dom_usage',
+ { cssText: 'some-css {}' },
+ FAKE_CEM,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('unexpected handler failure');
+ });
+});