Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/styling-preflight.md
Original file line number Diff line number Diff line change
@@ -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.
146 changes: 146 additions & 0 deletions packages/core/src/handlers/styling-preflight.ts
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +65 to +93
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not return a clean preflight when a sub-check crashes.

Both catch blocks suppress validator failures and continue. If either checker throws on an unexpected input shape, this function can still return pass even though the analysis was incomplete.

⚠️ Proposed fix
-    } catch {
-      // Shadow DOM check failed — skip
+    } catch {
+      issues.push({
+        severity: 'warning',
+        category: 'internal',
+        message: 'Shadow DOM validation could not be completed; preflight result is incomplete.',
+      });
     }
@@
-    } catch {
-      // Theme check failed — skip
+    } catch {
+      issues.push({
+        severity: 'warning',
+        category: 'internal',
+        message: 'Theme compatibility validation could not be completed; preflight result is incomplete.',
+      });
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
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 {
issues.push({
severity: 'warning',
category: 'internal',
message: 'Shadow DOM validation could not be completed; preflight result is incomplete.',
});
}
// 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 {
issues.push({
severity: 'warning',
category: 'internal',
message: 'Theme compatibility validation could not be completed; preflight result is incomplete.',
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/handlers/styling-preflight.ts` around lines 65 - 93, The
two empty catch blocks around checkShadowDomUsage and checkThemeCompatibility
are swallowing exceptions and allowing an incomplete analysis to return a clean
preflight; change each catch to capture the error (catch (err)) and either
rethrow or—preferably—append a new error issue to the same issues array (use
severity: 'error', category: 'shadowDom' or 'themeCompat', message: err.message
or String(err), and optional suggestion) and mark the overall result as failed
(e.g., set the preflight status variable or flag used by the function); update
the catch bodies in the blocks surrounding checkShadowDomUsage(...) and
checkThemeCompatibility(...) and ensure the calling code respects the failure
flag or thrown error.

}

// 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)`);
}
Comment on lines +141 to +143
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The verdict text is mislabeling non-theme warnings.

issues can contain shadowDom warnings, but the summary always describes every warning as a theme/dark-mode risk. That makes the returned verdict inaccurate for selector and Shadow DOM findings.

📝 Proposed fix
   if (warnings > 0) {
-    parts.push(`${warnings} warning${warnings > 1 ? 's' : ''} (theme/dark mode risk)`);
+    parts.push(`${warnings} warning${warnings > 1 ? 's' : ''}`);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (warnings > 0) {
parts.push(`${warnings} warning${warnings > 1 ? 's' : ''} (theme/dark mode risk)`);
}
if (warnings > 0) {
parts.push(`${warnings} warning${warnings > 1 ? 's' : ''}`);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/handlers/styling-preflight.ts` around lines 141 - 143, The
verdict text currently lumps all warnings as "theme/dark mode risk"; inspect the
collected issues (variable issues) and compute counts by type (e.g.,
theme/dark-mode issues vs shadowDom/selector issues) and then push an
appropriate summary string into parts (instead of the single generic
message)—for example pluralized messages for theme/dark-mode risks and separate
messages for Shadow DOM or selector warnings; update the logic that builds parts
(the block using warnings and parts) to check issue.kind or the specific fields
that identify shadowDom/selector items and emit accurate labels for each
category.


return 'fail — ' + parts.join(', ') + '.';
}
43 changes: 43 additions & 0 deletions packages/core/src/tools/styling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'],
},
},
];

/**
Expand Down Expand Up @@ -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));
}
Comment on lines +1013 to +1018
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add one integration test through handleStylingCall().

The new suite only calls runStylingPreflight() directly. A regression in tool registration, argument parsing, or response wrapping here would still pass CI even though the public MCP tool is broken.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/tools/styling.ts` around lines 1013 - 1018, The test suite
currently calls runStylingPreflight() directly and misses exercising the public
MCP entry, so add one integration test that invokes handleStylingCall() with a
simulated call for the 'styling_preflight' tool: construct args matching
StylingPreflightArgsSchema (cssText, tagName, htmlText), pass the same cem value
used in production, and assert the returned value is the wrapped success
response (created via createSuccessResponse) containing the JSON-stringified
runStylingPreflight result; this ensures tool registration, argument parsing
(StylingPreflightArgsSchema.parse), and response wrapping are validated
end-to-end rather than only testing runStylingPreflight in isolation.


return createErrorResponse(`Unknown styling tool: ${name}`);
} catch (err) {
const mcpErr = handleToolError(err);
Expand Down
Loading
Loading