Skip to content

Commit a19bf71

Browse files
authored
feat: add styling_preflight single-call validation tool
feat: add styling_preflight single-call validation tool
2 parents 3a9ded3 + ec1b8a9 commit a19bf71

File tree

5 files changed

+414
-1
lines changed

5 files changed

+414
-1
lines changed

.changeset/styling-preflight.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@anthropic/helixir-core': minor
3+
---
4+
5+
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.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* Styling Preflight — single-call tool that combines component API discovery,
3+
* CSS reference resolution, and validation into one response.
4+
*
5+
* Agents call this ONCE with their CSS (and optional HTML) to get:
6+
* 1. The component's full style API surface (parts, tokens, slots)
7+
* 2. Resolution of every ::part() and token reference (valid vs hallucinated)
8+
* 3. Validation issues (shadow DOM, theme, specificity)
9+
* 4. A correct CSS snippet to use as reference
10+
* 5. A pass/fail verdict
11+
*
12+
* This eliminates the "forgot to check the API first" failure mode.
13+
*/
14+
15+
import type { ComponentMetadata } from './cem.js';
16+
import { resolveCssApi, type CssApiResolution } from './css-api-resolver.js';
17+
import { checkShadowDomUsage } from './shadow-dom-checker.js';
18+
import { checkThemeCompatibility } from './theme-checker.js';
19+
import { buildCssSnippet } from './styling-diagnostics.js';
20+
21+
// ─── Types ───────────────────────────────────────────────────────────────────
22+
23+
export interface PreflightInput {
24+
css: string;
25+
html?: string;
26+
meta: ComponentMetadata;
27+
}
28+
29+
export interface PreflightIssue {
30+
severity: 'error' | 'warning' | 'info';
31+
category: string;
32+
message: string;
33+
line?: number;
34+
suggestion?: string;
35+
}
36+
37+
export interface PreflightComponentApi {
38+
tagName: string;
39+
description: string;
40+
parts: string[];
41+
tokens: string[];
42+
slots: string[];
43+
hasStyleApi: boolean;
44+
}
45+
46+
export interface PreflightResult {
47+
componentApi: PreflightComponentApi;
48+
resolution: CssApiResolution;
49+
issues: PreflightIssue[];
50+
correctSnippet: string;
51+
verdict: string;
52+
}
53+
54+
// ─── Main Entry Point ───────────────────────────────────────────────────────
55+
56+
export function runStylingPreflight(input: PreflightInput): PreflightResult {
57+
const { css, html, meta } = input;
58+
const issues: PreflightIssue[] = [];
59+
60+
// 1. Resolve CSS references against CEM
61+
const resolution = resolveCssApi(css, meta, html);
62+
63+
// 2. Run shadow DOM validation (if CSS is non-empty)
64+
if (css.trim()) {
65+
try {
66+
const shadowResult = checkShadowDomUsage(css, meta.tagName, meta);
67+
for (const issue of shadowResult.issues) {
68+
issues.push({
69+
severity: issue.severity === 'error' ? 'error' : 'warning',
70+
category: 'shadowDom',
71+
message: issue.message,
72+
line: issue.line,
73+
suggestion: issue.suggestion,
74+
});
75+
}
76+
} catch {
77+
// Shadow DOM check failed — skip
78+
}
79+
80+
// 3. Run theme compatibility check
81+
try {
82+
const themeResult = checkThemeCompatibility(css);
83+
for (const issue of themeResult.issues) {
84+
issues.push({
85+
severity: 'warning',
86+
category: 'themeCompat',
87+
message: issue.message,
88+
line: issue.line,
89+
});
90+
}
91+
} catch {
92+
// Theme check failed — skip
93+
}
94+
}
95+
96+
// 4. Build the component API summary
97+
const componentApi: PreflightComponentApi = {
98+
tagName: meta.tagName,
99+
description: meta.description,
100+
parts: meta.cssParts.map((p) => p.name),
101+
tokens: meta.cssProperties.map((p) => p.name),
102+
slots: meta.slots.map((s) => (s.name === '' ? '(default)' : s.name)),
103+
hasStyleApi: meta.cssParts.length > 0 || meta.cssProperties.length > 0 || meta.slots.length > 0,
104+
};
105+
106+
// 5. Generate correct CSS snippet
107+
const correctSnippet = buildCssSnippet(meta);
108+
109+
// 6. Build verdict
110+
const verdict = buildVerdict(resolution, issues);
111+
112+
return {
113+
componentApi,
114+
resolution,
115+
issues,
116+
correctSnippet,
117+
verdict,
118+
};
119+
}
120+
121+
// ─── Verdict Builder ────────────────────────────────────────────────────────
122+
123+
function buildVerdict(resolution: CssApiResolution, issues: PreflightIssue[]): string {
124+
const errors = issues.filter((i) => i.severity === 'error').length;
125+
const warnings = issues.filter((i) => i.severity === 'warning').length;
126+
const invalidRefs = resolution.invalidCount;
127+
128+
if (errors === 0 && warnings === 0 && invalidRefs === 0) {
129+
return 'pass — CSS references are valid and no anti-patterns detected.';
130+
}
131+
132+
const parts: string[] = [];
133+
if (invalidRefs > 0) {
134+
parts.push(
135+
`${invalidRefs} invalid CSS reference${invalidRefs > 1 ? 's' : ''} (hallucinated part/token/slot names)`,
136+
);
137+
}
138+
if (errors > 0) {
139+
parts.push(`${errors} error${errors > 1 ? 's' : ''} (will break at runtime)`);
140+
}
141+
if (warnings > 0) {
142+
parts.push(`${warnings} warning${warnings > 1 ? 's' : ''} (theme/dark mode risk)`);
143+
}
144+
145+
return 'fail — ' + parts.join(', ') + '.';
146+
}

packages/core/src/tools/styling.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { checkColorContrast } from '../handlers/color-contrast-checker.js';
2828
import { checkTransitionAnimation } from '../handlers/transition-checker.js';
2929
import { checkShadowDomJs } from '../handlers/shadow-dom-js-checker.js';
3030
import { resolveCssApi } from '../handlers/css-api-resolver.js';
31+
import { runStylingPreflight } from '../handlers/styling-preflight.js';
3132
import { createErrorResponse, createSuccessResponse } from '../shared/mcp-helpers.js';
3233
import type { MCPToolResult } from '../shared/mcp-helpers.js';
3334
import { handleToolError } from '../shared/error-handling.js';
@@ -166,6 +167,12 @@ const ResolveCssApiArgsSchema = z.object({
166167
htmlText: z.string().optional(),
167168
});
168169

170+
const StylingPreflightArgsSchema = z.object({
171+
cssText: z.string(),
172+
tagName: z.string(),
173+
htmlText: z.string().optional(),
174+
});
175+
169176
export const STYLING_TOOL_DEFINITIONS = [
170177
{
171178
name: 'diagnose_styling',
@@ -788,6 +795,35 @@ export const STYLING_TOOL_DEFINITIONS = [
788795
required: ['cssText', 'tagName'],
789796
},
790797
},
798+
{
799+
name: 'styling_preflight',
800+
description:
801+
"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.",
802+
inputSchema: {
803+
type: 'object' as const,
804+
properties: {
805+
libraryId: {
806+
type: 'string',
807+
description:
808+
'Optional library ID to target a specific loaded library instead of the default.',
809+
},
810+
cssText: {
811+
type: 'string',
812+
description: 'The CSS code to validate against the component API.',
813+
},
814+
tagName: {
815+
type: 'string',
816+
description: 'The custom element tag name (e.g. "hx-button").',
817+
},
818+
htmlText: {
819+
type: 'string',
820+
description:
821+
'Optional HTML code to validate slot attribute references against the component API.',
822+
},
823+
},
824+
required: ['cssText', 'tagName'],
825+
},
826+
},
791827
];
792828

793829
/**
@@ -974,6 +1010,13 @@ export function handleStylingCall(
9741010
return createSuccessResponse(JSON.stringify(result, null, 2));
9751011
}
9761012

1013+
if (name === 'styling_preflight') {
1014+
const { cssText, tagName, htmlText } = StylingPreflightArgsSchema.parse(args);
1015+
const meta = parseCem(tagName, cem);
1016+
const result = runStylingPreflight({ css: cssText, html: htmlText, meta });
1017+
return createSuccessResponse(JSON.stringify(result, null, 2));
1018+
}
1019+
9771020
return createErrorResponse(`Unknown styling tool: ${name}`);
9781021
} catch (err) {
9791022
const mcpErr = handleToolError(err);

0 commit comments

Comments
 (0)