diff --git a/.changeset/styling-preflight.md b/.changeset/styling-preflight.md new file mode 100644 index 0000000..ea5858d --- /dev/null +++ b/.changeset/styling-preflight.md @@ -0,0 +1,5 @@ +--- +'@anthropic/helixir-core': minor +--- + +Add `styling_preflight` MCP tool — single-call styling validation that combines component API discovery, CSS reference resolution, Shadow DOM anti-pattern detection, theme compatibility checking, and a pass/fail verdict. Eliminates the "forgot to check the API first" failure mode. diff --git a/packages/core/src/handlers/styling-preflight.ts b/packages/core/src/handlers/styling-preflight.ts new file mode 100644 index 0000000..98af318 --- /dev/null +++ b/packages/core/src/handlers/styling-preflight.ts @@ -0,0 +1,146 @@ +/** + * Styling Preflight — single-call tool that combines component API discovery, + * CSS reference resolution, and validation into one response. + * + * Agents call this ONCE with their CSS (and optional HTML) to get: + * 1. The component's full style API surface (parts, tokens, slots) + * 2. Resolution of every ::part() and token reference (valid vs hallucinated) + * 3. Validation issues (shadow DOM, theme, specificity) + * 4. A correct CSS snippet to use as reference + * 5. A pass/fail verdict + * + * This eliminates the "forgot to check the API first" failure mode. + */ + +import type { ComponentMetadata } from './cem.js'; +import { resolveCssApi, type CssApiResolution } from './css-api-resolver.js'; +import { checkShadowDomUsage } from './shadow-dom-checker.js'; +import { checkThemeCompatibility } from './theme-checker.js'; +import { buildCssSnippet } from './styling-diagnostics.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface PreflightInput { + css: string; + html?: string; + meta: ComponentMetadata; +} + +export interface PreflightIssue { + severity: 'error' | 'warning' | 'info'; + category: string; + message: string; + line?: number; + suggestion?: string; +} + +export interface PreflightComponentApi { + tagName: string; + description: string; + parts: string[]; + tokens: string[]; + slots: string[]; + hasStyleApi: boolean; +} + +export interface PreflightResult { + componentApi: PreflightComponentApi; + resolution: CssApiResolution; + issues: PreflightIssue[]; + correctSnippet: string; + verdict: string; +} + +// ─── Main Entry Point ─────────────────────────────────────────────────────── + +export function runStylingPreflight(input: PreflightInput): PreflightResult { + const { css, html, meta } = input; + const issues: PreflightIssue[] = []; + + // 1. Resolve CSS references against CEM + const resolution = resolveCssApi(css, meta, html); + + // 2. Run shadow DOM validation (if CSS is non-empty) + if (css.trim()) { + try { + const shadowResult = checkShadowDomUsage(css, meta.tagName, meta); + for (const issue of shadowResult.issues) { + issues.push({ + severity: issue.severity === 'error' ? 'error' : 'warning', + category: 'shadowDom', + message: issue.message, + line: issue.line, + suggestion: issue.suggestion, + }); + } + } catch { + // Shadow DOM check failed — skip + } + + // 3. Run theme compatibility check + try { + const themeResult = checkThemeCompatibility(css); + for (const issue of themeResult.issues) { + issues.push({ + severity: 'warning', + category: 'themeCompat', + message: issue.message, + line: issue.line, + }); + } + } catch { + // Theme check failed — skip + } + } + + // 4. Build the component API summary + const componentApi: PreflightComponentApi = { + tagName: meta.tagName, + description: meta.description, + parts: meta.cssParts.map((p) => p.name), + tokens: meta.cssProperties.map((p) => p.name), + slots: meta.slots.map((s) => (s.name === '' ? '(default)' : s.name)), + hasStyleApi: meta.cssParts.length > 0 || meta.cssProperties.length > 0 || meta.slots.length > 0, + }; + + // 5. Generate correct CSS snippet + const correctSnippet = buildCssSnippet(meta); + + // 6. Build verdict + const verdict = buildVerdict(resolution, issues); + + return { + componentApi, + resolution, + issues, + correctSnippet, + verdict, + }; +} + +// ─── Verdict Builder ──────────────────────────────────────────────────────── + +function buildVerdict(resolution: CssApiResolution, issues: PreflightIssue[]): string { + const errors = issues.filter((i) => i.severity === 'error').length; + const warnings = issues.filter((i) => i.severity === 'warning').length; + const invalidRefs = resolution.invalidCount; + + if (errors === 0 && warnings === 0 && invalidRefs === 0) { + return 'pass — CSS references are valid and no anti-patterns detected.'; + } + + const parts: string[] = []; + if (invalidRefs > 0) { + parts.push( + `${invalidRefs} invalid CSS reference${invalidRefs > 1 ? 's' : ''} (hallucinated part/token/slot names)`, + ); + } + if (errors > 0) { + parts.push(`${errors} error${errors > 1 ? 's' : ''} (will break at runtime)`); + } + if (warnings > 0) { + parts.push(`${warnings} warning${warnings > 1 ? 's' : ''} (theme/dark mode risk)`); + } + + return 'fail — ' + parts.join(', ') + '.'; +} diff --git a/packages/core/src/tools/styling.ts b/packages/core/src/tools/styling.ts index 2dd2262..2a55382 100644 --- a/packages/core/src/tools/styling.ts +++ b/packages/core/src/tools/styling.ts @@ -28,6 +28,7 @@ import { checkColorContrast } from '../handlers/color-contrast-checker.js'; import { checkTransitionAnimation } from '../handlers/transition-checker.js'; import { checkShadowDomJs } from '../handlers/shadow-dom-js-checker.js'; import { resolveCssApi } from '../handlers/css-api-resolver.js'; +import { runStylingPreflight } from '../handlers/styling-preflight.js'; import { createErrorResponse, createSuccessResponse } from '../shared/mcp-helpers.js'; import type { MCPToolResult } from '../shared/mcp-helpers.js'; import { handleToolError } from '../shared/error-handling.js'; @@ -166,6 +167,12 @@ const ResolveCssApiArgsSchema = z.object({ htmlText: z.string().optional(), }); +const StylingPreflightArgsSchema = z.object({ + cssText: z.string(), + tagName: z.string(), + htmlText: z.string().optional(), +}); + export const STYLING_TOOL_DEFINITIONS = [ { name: 'diagnose_styling', @@ -788,6 +795,35 @@ export const STYLING_TOOL_DEFINITIONS = [ required: ['cssText', 'tagName'], }, }, + { + name: 'styling_preflight', + description: + "Single-call styling validation that combines component API discovery, CSS reference resolution, and anti-pattern detection. Returns: the component's full style API surface (parts, tokens, slots), valid/invalid status for every ::part() and token reference, Shadow DOM and theme validation issues, a correct CSS snippet, and a pass/fail verdict. Call this ONCE before finalizing any component CSS to prevent hallucinated part names, invalid tokens, and Shadow DOM mistakes.", + inputSchema: { + type: 'object' as const, + properties: { + libraryId: { + type: 'string', + description: + 'Optional library ID to target a specific loaded library instead of the default.', + }, + cssText: { + type: 'string', + description: 'The CSS code to validate against the component API.', + }, + tagName: { + type: 'string', + description: 'The custom element tag name (e.g. "hx-button").', + }, + htmlText: { + type: 'string', + description: + 'Optional HTML code to validate slot attribute references against the component API.', + }, + }, + required: ['cssText', 'tagName'], + }, + }, ]; /** @@ -974,6 +1010,13 @@ export function handleStylingCall( return createSuccessResponse(JSON.stringify(result, null, 2)); } + if (name === 'styling_preflight') { + const { cssText, tagName, htmlText } = StylingPreflightArgsSchema.parse(args); + const meta = parseCem(tagName, cem); + const result = runStylingPreflight({ css: cssText, html: htmlText, meta }); + return createSuccessResponse(JSON.stringify(result, null, 2)); + } + return createErrorResponse(`Unknown styling tool: ${name}`); } catch (err) { const mcpErr = handleToolError(err); diff --git a/tests/handlers/styling-preflight.test.ts b/tests/handlers/styling-preflight.test.ts new file mode 100644 index 0000000..502d560 --- /dev/null +++ b/tests/handlers/styling-preflight.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { runStylingPreflight } from '../../packages/core/src/handlers/styling-preflight.js'; +import type { ComponentMetadata } from '../../packages/core/src/handlers/cem.js'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const buttonMeta: ComponentMetadata = { + tagName: 'hx-button', + name: 'HxButton', + description: 'A button component', + members: [ + { + name: 'variant', + kind: 'field', + type: "'primary' | 'secondary' | 'danger'", + description: 'Visual style variant', + }, + { + name: 'disabled', + kind: 'field', + type: 'boolean', + description: 'Disables the button', + }, + ], + events: [{ name: 'hx-click', type: 'CustomEvent', description: 'Fired on click' }], + slots: [ + { name: '', description: 'Default slot for label text' }, + { name: 'prefix', description: 'Icon before the label' }, + ], + cssProperties: [ + { name: '--hx-button-bg', description: 'Background color' }, + { name: '--hx-button-color', description: 'Text color' }, + { name: '--hx-button-radius', description: 'Border radius', default: '4px' }, + ], + cssParts: [ + { name: 'base', description: 'The button wrapper' }, + { name: 'label', description: 'The label container' }, + ], +}; + +const bareMeta: ComponentMetadata = { + tagName: 'x-bare', + name: 'XBare', + description: 'A bare component with no CSS API', + members: [], + events: [], + slots: [], + cssProperties: [], + cssParts: [], +}; + +// ─── Result Shape ──────────────────────────────────────────────────────────── + +describe('runStylingPreflight — result shape', () => { + it('returns component API summary', () => { + const result = runStylingPreflight({ + css: 'hx-button { --hx-button-bg: blue; }', + meta: buttonMeta, + }); + expect(result.componentApi.tagName).toBe('hx-button'); + expect(result.componentApi.parts).toEqual(['base', 'label']); + expect(result.componentApi.tokens).toEqual([ + '--hx-button-bg', + '--hx-button-color', + '--hx-button-radius', + ]); + expect(result.componentApi.slots).toEqual(['(default)', 'prefix']); + }); + + it('returns resolution results', () => { + const result = runStylingPreflight({ + css: 'hx-button::part(base) { --hx-button-bg: blue; }', + meta: buttonMeta, + }); + expect(result.resolution).toBeDefined(); + expect(result.resolution.valid).toBe(true); + }); + + it('returns issues array', () => { + const result = runStylingPreflight({ + css: 'hx-button .inner { color: red; }', + meta: buttonMeta, + }); + expect(result.issues).toBeDefined(); + expect(Array.isArray(result.issues)).toBe(true); + }); + + it('returns correct CSS snippet', () => { + const result = runStylingPreflight({ + css: '', + meta: buttonMeta, + }); + expect(result.correctSnippet).toBeDefined(); + expect(result.correctSnippet).toContain('hx-button'); + }); +}); + +// ─── Validation Integration ────────────────────────────────────────────────── + +describe('runStylingPreflight — validation', () => { + it('catches descendant selector piercing shadow DOM', () => { + const result = runStylingPreflight({ + css: 'hx-button .inner { color: red; }', + meta: buttonMeta, + }); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issues.some((i) => i.category === 'shadowDom')).toBe(true); + }); + + it('catches unknown ::part() names', () => { + const result = runStylingPreflight({ + css: 'hx-button::part(footer) { color: red; }', + meta: buttonMeta, + }); + expect(result.resolution.valid).toBe(false); + expect(result.resolution.parts.resolved[0]?.valid).toBe(false); + }); + + it('catches unknown CSS custom properties', () => { + const result = runStylingPreflight({ + css: 'hx-button { --hx-button-fake: red; }', + meta: buttonMeta, + }); + expect(result.resolution.valid).toBe(false); + expect(result.resolution.tokens.resolved[0]?.valid).toBe(false); + }); + + it('catches hardcoded colors in theme-sensitive properties', () => { + const result = runStylingPreflight({ + css: 'hx-button { color: #333333; background: white; }', + meta: buttonMeta, + }); + expect(result.issues.some((i) => i.category === 'themeCompat')).toBe(true); + }); + + it('reports clean for valid CSS', () => { + const result = runStylingPreflight({ + css: 'hx-button::part(base) { --hx-button-bg: var(--my-theme-color, blue); }', + meta: buttonMeta, + }); + expect(result.resolution.valid).toBe(true); + }); +}); + +// ─── HTML Validation ───────────────────────────────────────────────────────── + +describe('runStylingPreflight — HTML validation', () => { + it('validates slot names in HTML', () => { + const result = runStylingPreflight({ + css: '', + html: 'X', + meta: buttonMeta, + }); + expect(result.resolution.slots.resolved[0]?.valid).toBe(false); + }); + + it('validates valid slot names', () => { + const result = runStylingPreflight({ + css: '', + html: 'X', + meta: buttonMeta, + }); + expect(result.resolution.slots.resolved[0]?.valid).toBe(true); + }); +}); + +// ─── Bare Components ───────────────────────────────────────────────────────── + +describe('runStylingPreflight — bare component', () => { + it('warns when trying to style a component with no CSS API', () => { + const result = runStylingPreflight({ + css: 'x-bare::part(base) { color: red; }', + meta: bareMeta, + }); + expect(result.componentApi.hasStyleApi).toBe(false); + expect(result.resolution.valid).toBe(false); + }); + + it('returns empty API surface', () => { + const result = runStylingPreflight({ + css: '', + meta: bareMeta, + }); + expect(result.componentApi.parts).toHaveLength(0); + expect(result.componentApi.tokens).toHaveLength(0); + expect(result.componentApi.slots).toHaveLength(0); + }); +}); + +// ─── Overall Verdict ───────────────────────────────────────────────────────── + +describe('runStylingPreflight — verdict', () => { + it('includes a human-readable verdict', () => { + const result = runStylingPreflight({ + css: 'hx-button .inner { color: red; }', + meta: buttonMeta, + }); + expect(typeof result.verdict).toBe('string'); + expect(result.verdict.length).toBeGreaterThan(0); + }); + + it('reports pass verdict for clean CSS', () => { + const result = runStylingPreflight({ + css: 'hx-button { --hx-button-bg: blue; }', + meta: buttonMeta, + }); + expect(result.verdict).toContain('pass'); + }); + + it('reports fail verdict for issues', () => { + const result = runStylingPreflight({ + css: 'hx-button .inner { color: red; }', + meta: buttonMeta, + }); + expect(result.verdict.toLowerCase()).toMatch(/fail|issue|error/); + }); +}); diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index f180c67..a52e457 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -140,7 +140,7 @@ describe.skipIf(!SERVER_AVAILABLE)('MCP server integration (with tokensPath conf }); }); - it('returns all expected tool names (66 core + 2 token when configured)', async () => { + it('returns all expected tool names (68 core + 2 token when configured)', async () => { sendRequest('tools/list', {}); const response = await recv(); @@ -227,6 +227,8 @@ describe.skipIf(!SERVER_AVAILABLE)('MCP server integration (with tokensPath conf 'check_color_contrast', 'check_transition_animation', 'check_shadow_dom_js', + 'resolve_css_api', + 'styling_preflight', ]; const tokenTools = ['get_design_tokens', 'find_token']; const expectedTools = [...coreTools, ...tokenTools];