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];