From 7a754c88fef2a77b35314689da748ef6494a2233 Mon Sep 17 00:00:00 2001 From: Simeon Simeonoff Date: Mon, 2 Nov 2026 11:06:13 +0200 Subject: [PATCH 1/5] chore(mcp): add more aliases for navdrawer --- packages/mcp/src/knowledge/component-metadata.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/mcp/src/knowledge/component-metadata.ts b/packages/mcp/src/knowledge/component-metadata.ts index 693862d2..c97b280b 100644 --- a/packages/mcp/src/knowledge/component-metadata.ts +++ b/packages/mcp/src/knowledge/component-metadata.ts @@ -544,9 +544,13 @@ Both themes should share the same visual treatment as the file-input wrapper.`, "drawer", "side drawer", "side nav", + "side panel", "sidenav", + "sidebar", "navigation drawer", "navigation panel", + "nav-drawer", + "nav drawer", ], }, "nav-drawer-item": { From 65e4897ae1b133dcd2e049e4ffa8aad9cb0de441 Mon Sep 17 00:00:00 2001 From: Adrian Petrov Date: Tue, 3 Nov 2026 11:34:50 +0200 Subject: [PATCH 2/5] feat(): add light and dark description and "both" variant option --- .../__tests__/contract/tool-output.test.ts | 2 +- .../__tests__/tools/handlers/handlers.test.ts | 65 +++++++++++++++++++ packages/mcp/src/generators/css.ts | 51 +++++++++------ packages/mcp/src/generators/sass.ts | 63 ++++++++++-------- packages/mcp/src/tools/descriptions.ts | 12 +++- .../mcp/src/tools/handlers/component-theme.ts | 41 ++++++++++-- packages/mcp/src/tools/schemas.ts | 5 +- packages/mcp/src/utils/sass.ts | 11 ++++ packages/mcp/src/utils/types.ts | 5 ++ 9 files changed, 201 insertions(+), 54 deletions(-) diff --git a/packages/mcp/src/__tests__/contract/tool-output.test.ts b/packages/mcp/src/__tests__/contract/tool-output.test.ts index d1b9683a..fed9d669 100644 --- a/packages/mcp/src/__tests__/contract/tool-output.test.ts +++ b/packages/mcp/src/__tests__/contract/tool-output.test.ts @@ -240,7 +240,7 @@ describe("tool output compiles", () => { const cases = cartesian({ platform: PRODUCT_PLATFORMS, designSystem: DESIGN_SYSTEMS, - variant: VARIANTS, + variant: [...VARIANTS, "both"], output: OUTPUTS, component: ["flat-button", "avatar"], }); diff --git a/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts b/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts index dd92bfd4..2bd728a6 100644 --- a/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts +++ b/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts @@ -465,6 +465,71 @@ describe("handleCreateCustomPalette", () => { }); describe("handleCreateComponentTheme", () => { + it("tells agents how to add a scoped dark variant after a light call", async () => { + const result = await handleCreateComponentTheme({ + platform: "webcomponents", + component: "avatar", + tokens: { background: "#ff5722" }, + output: "css", + }); + + const text = result.content[0].text; + expect(text).toContain("covers the light variant only"); + expect(text).toContain('variant: "dark"'); + expect(text).toContain('".your-dark-scope igc-avatar"'); + expect(text).not.toContain('"both"'); + }); + + it("tells agents to scope a dark call and add the light variant", async () => { + const result = await handleCreateComponentTheme({ + platform: "angular", + component: "avatar", + variant: "dark", + tokens: { background: "#ff5722" }, + output: "sass", + }); + + const text = result.content[0].text; + expect(text).toContain("covers the dark variant only"); + expect(text).toContain('".your-dark-scope igx-avatar"'); + expect(text).toContain('variant: "light"'); + }); + + it("returns light and scoped dark CSS blocks for variant both", async () => { + const result = await handleCreateComponentTheme({ + platform: "webcomponents", + component: "avatar", + variant: "both", + tokens: { background: "#ff5722" }, + output: "css", + }); + + expect(result.isError).toBeUndefined(); + const text = result.content[0].text; + expect(text).toContain("(light + dark)"); + expect(text).toMatch(/^igc-avatar \{/m); + expect(text).toMatch(/^\.your-dark-scope igc-avatar \{/m); + expect(text).toContain("Replace `.your-dark-scope`"); + }); + + it("returns light and dark Sass themes with one set of @use rules for variant both", async () => { + const result = await handleCreateComponentTheme({ + platform: "angular", + component: "avatar", + variant: "both", + tokens: { background: "#ff5722" }, + output: "sass", + }); + + expect(result.isError).toBeUndefined(); + const text = result.content[0].text; + expect(text).toContain("$schema: $light-material-schema"); + expect(text).toContain("$schema: $dark-material-schema"); + expect(text).toContain("$custom-avatar-theme-dark"); + expect(text).toContain(".your-dark-scope igx-avatar {"); + expect(text.match(/^@use /gm)).toHaveLength(1); + }); + it("returns error when platform is not provided", async () => { const result = await handleCreateComponentTheme({ component: "avatar", diff --git a/packages/mcp/src/generators/css.ts b/packages/mcp/src/generators/css.ts index 6db80662..9b116f8a 100644 --- a/packages/mcp/src/generators/css.ts +++ b/packages/mcp/src/generators/css.ts @@ -241,7 +241,8 @@ export async function generateComponentThemeCss( getThemingSelector, SCHEMA_PRESETS, } = await import("../knowledge/index.js"); - const { toVariableName } = await import("../utils/sass.js"); + const { DARK_SCOPE_PLACEHOLDER, scopeSelector, toVariableName } = + await import("../utils/sass.js"); // Validate component exists const theme = getComponentTheme(options.component); @@ -260,14 +261,8 @@ export async function generateComponentThemeCss( ? `$${toVariableName(options.name)}` : `$custom-${themeComponentName}-theme`; - // Get the schema variable based on design system and variant - const schemaVar = - SCHEMA_PRESETS[variant as "light" | "dark"][ - designSystem as "material" | "indigo" | "bootstrap" | "fluent" - ]; - - // Build token arguments - schema comes first - const tokenArgs: string[] = [`$schema: ${schemaVar}`]; + // Build token arguments - the schema is added per variant below + const tokenArgs: string[] = []; for (const [tokenName, value] of Object.entries(options.tokens)) { const stringValue = typeof value === "number" ? String(value) : value; @@ -284,20 +279,36 @@ export async function generateComponentThemeCss( options.selector || (defaultSelectors.length > 0 ? defaultSelectors[0] : options.component); + // "both" adds a dark block with its own variable, scoped under a placeholder + const variants = variant === "both" ? ["light", "dark"] : [variant]; + const blocks = variants.map((v) => { + const isDark = variant === "both" && v === "dark"; + const varName = isDark ? `${themeName}-dark` : themeName; + const varSelector = isDark + ? scopeSelector(DARK_SCOPE_PLACEHOLDER, selector) + : selector; + const schemaVar = + SCHEMA_PRESETS[v as "light" | "dark"][ + designSystem as "material" | "indigo" | "bootstrap" | "fluent" + ]; + + return ` +// Custom ${themeComponentName} theme +${varName}: ${themeFn}( + ${[`$schema: ${schemaVar}`, ...tokenArgs].join(",\n ")} +); + +// Apply the theme to ${varSelector} +${varSelector} { + @include tokens(${varName}); +}`; + }); + // Generate Sass code const sassCode = ` @use 'igniteui-theming/sass/themes' as *; @use 'igniteui-theming/sass/themes/schemas' as *; - -// Custom ${themeComponentName} theme -${themeName}: ${themeFn}( - ${tokenArgs.join(",\n ")} -); - -// Apply the theme to ${selector} -${selector} { - @include tokens(${themeName}); -} +${blocks.join("\n")} `; try { @@ -309,7 +320,7 @@ ${selector} { return { css: result.css, - description: `Generated CSS custom properties for ${themeComponentName} component with ${Object.keys(options.tokens).length} token(s) using ${designSystem} design system (${variant} variant)`, + description: `Generated CSS custom properties for ${themeComponentName} component with ${Object.keys(options.tokens).length} token(s) using ${designSystem} design system (${variant === "both" ? "light + dark" : variant} variant)`, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/mcp/src/generators/sass.ts b/packages/mcp/src/generators/sass.ts index aabc0831..a1320582 100644 --- a/packages/mcp/src/generators/sass.ts +++ b/packages/mcp/src/generators/sass.ts @@ -22,13 +22,16 @@ import { import { generateWebComponentsThemeSass } from "../knowledge/platforms/webcomponents.js"; import { TYPOGRAPHY_PRESETS } from "../knowledge/typography.js"; import { + DARK_SCOPE_PLACEHOLDER, generateHeader, generatePresetImports, generateUseStatement, quoteFontFamily, + scopeSelector, toVariableName, } from "../utils/sass.js"; import type { + ComponentThemeVariant, CreateElevationsInput, CreatePaletteInput, CreateThemeInput, @@ -449,8 +452,8 @@ export interface CreateComponentThemeInput { licensed?: boolean; /** Design system (defaults to 'material') */ designSystem?: DesignSystem; - /** Theme variant - light or dark (defaults to 'light') */ - variant?: ThemeVariant; + /** Theme variant - light, dark, or both (defaults to 'light') */ + variant?: ComponentThemeVariant; /** Component name (e.g., "flat-button", "avatar") */ component: string; /** Token name-value pairs */ @@ -474,7 +477,7 @@ export function generateComponentTheme( } const designSystem: DesignSystem = input.designSystem ?? "material"; - const variant: ThemeVariant = input.variant ?? "light"; + const variant: ComponentThemeVariant = input.variant ?? "light"; const themeFn = theme.themeFunctionName; // For child components, derive variable name from parent to avoid // generating a separate variable that signals "different theme" @@ -484,11 +487,8 @@ export function generateComponentTheme( ? `$${toVariableName(input.name)}` : `$custom-${themeComponentName}-theme`; - // Get the schema variable based on design system and variant - const schemaVar = SCHEMA_PRESETS[variant][designSystem]; - - // Build token arguments - schema comes first - const tokenArgs: string[] = [`$schema: ${schemaVar}`]; + // Build token arguments - the schema is added per variant below + const tokenArgs: string[] = []; for (const [tokenName, value] of Object.entries(input.tokens)) { // Convert value to string if needed const stringValue = typeof value === "number" ? String(value) : value; @@ -514,30 +514,41 @@ export function generateComponentTheme( sections.push(`@use '${SCHEMAS_PATH}' as *;`); } - sections.push( - "", - `// Custom ${themeComponentName} theme`, - `${themeName}: ${themeFn}(`, - ); + // "both" adds a dark block with its own variable, scoped under a placeholder + const variants: ThemeVariant[] = + variant === "both" ? ["light", "dark"] : [variant]; + const variables: string[] = []; - // Add token arguments with proper indentation - if (tokenArgs.length > 0) { - sections.push(` ${tokenArgs.join(",\n ")}`); - } - sections.push(");"); + for (const v of variants) { + const isDark = variant === "both" && v === "dark"; + const varName = isDark ? `${themeName}-dark` : themeName; + const varSelector = isDark + ? scopeSelector(DARK_SCOPE_PLACEHOLDER, selector) + : selector; + const args = [`$schema: ${SCHEMA_PRESETS[v][designSystem]}`, ...tokenArgs]; + variables.push(varName); + + sections.push( + "", + `// Custom ${themeComponentName} theme`, + `${varName}: ${themeFn}(`, + ` ${args.join(",\n ")}`, + ");", + ); - // Apply the theme using tokens mixin (global mode) - sections.push(""); - sections.push(`// Apply the theme to ${selector}`); - sections.push(`${selector} {`); - sections.push(` @include tokens(${themeName});`); - sections.push("}"); + // Apply the theme using tokens mixin (global mode) + sections.push(""); + sections.push(`// Apply the theme to ${varSelector}`); + sections.push(`${varSelector} {`); + sections.push(` @include tokens(${varName});`); + sections.push("}"); + } const code = `${sections.join("\n")}\n`; return { code, - description: `Generated custom ${themeComponentName} theme with ${Object.keys(input.tokens).length} token(s) using ${designSystem} design system (${variant} variant)`, - variables: [themeName], + description: `Generated custom ${themeComponentName} theme with ${Object.keys(input.tokens).length} token(s) using ${designSystem} design system (${variant === "both" ? "light + dark" : variant} variant)`, + variables, }; } diff --git a/packages/mcp/src/tools/descriptions.ts b/packages/mcp/src/tools/descriptions.ts index 61c3a4f6..ec66ded2 100644 --- a/packages/mcp/src/tools/descriptions.ts +++ b/packages/mcp/src/tools/descriptions.ts @@ -857,12 +857,19 @@ export const TOOL_DESCRIPTIONS = { DESIGN SYSTEM & VARIANT: - designSystem: Choose "material" (default), "bootstrap", "fluent", or "indigo" - - variant: Choose "light" (default) or "dark" + - variant: Choose "light" (default), "dark", or "both" - The correct schema (e.g., $light-bootstrap-schema, $dark-material-schema) is automatically selected and passed to the component theme function - The correct output: Choose output format based on the target file type (Sass vs CSS) - This ensures component tokens inherit proper defaults from the design system + LIGHT & DARK: + - Each call produces one variant. If the app supports light and dark, call this tool + for each variant — do not ship only the light one. + - Scope the dark call under the app's dark-mode selector so it doesn't override the + light theme (e.g. ".your-dark-scope igc-avatar"). + - The response includes a note with the scoped selector for the other variant. + TOKEN VALIDATION: - All provided token names are validated against the component's schema - Invalid tokens return an error with the list of valid token names @@ -919,6 +926,7 @@ export const TOOL_DESCRIPTIONS = { - Description of what was generated - Design system and variant used - List of tokens used + - A light/dark note on covering the other variant @@ -1275,6 +1283,8 @@ Important: Gray progression is INVERTED for dark themes (50=darkest, 900=lightes // --------------------------------------------------------------------------- // Component theming parameters // --------------------------------------------------------------------------- + componentThemeVariant: `Theme variant: "light" (default), "dark", or "both". "both" returns a light block on the default selector and a dark block scoped under the \`.your-dark-scope\` placeholder, which must be replaced with the app's dark-mode selector.`, + component: `Component name to get design tokens for (e.g., "button", "avatar", "grid"). Use exact names like "flat-button" for button variants. Child sub-component names like "list-item", "card-header", "accordion-header", "tab-item", "step" are also supported — they resolve to the parent component's theme. Call this tool to discover available tokens BEFORE using create_component_theme.`, componentTheme: `Component name to theme (e.g., "button", "avatar", "flat-button", "grid"). Must match a valid component from get_component_design_tokens. For button/icon-button variants, use specific names like "flat-button", "contained-button", "outlined-button", "fab-button". Child sub-component names (e.g., "list-item", "card-header") are supported and automatically resolve to the parent theme with the parent's selector and variable name.`, diff --git a/packages/mcp/src/tools/handlers/component-theme.ts b/packages/mcp/src/tools/handlers/component-theme.ts index 80520f38..e16c12f7 100644 --- a/packages/mcp/src/tools/handlers/component-theme.ts +++ b/packages/mcp/src/tools/handlers/component-theme.ts @@ -18,7 +18,11 @@ import { validateTokens, } from "../../knowledge/index.js"; import { PLATFORM_METADATA } from "../../knowledge/platforms/index.js"; -import { SASS_USE_ASSEMBLY_NOTE } from "../../utils/sass.js"; +import { + DARK_SCOPE_PLACEHOLDER, + SASS_USE_ASSEMBLY_NOTE, + scopeSelector, +} from "../../utils/sass.js"; import type { CreateComponentThemeParams } from "../schemas.js"; /** @@ -55,6 +59,27 @@ function getComposedTokenWarning( ); } +/** + * Build a note on covering both light and dark variants. + * A single call produces one variant, and the app's dark-mode selector isn't known, + * so the dark block has to be scoped by the agent. + */ +function getVariantNote(variant: string, selector: string | undefined): string { + if (variant === "both") { + return `\n\nšŸŒ— **Light & dark:** Replace \`${DARK_SCOPE_PLACEHOLDER}\` with your app's dark-mode selector.`; + } + + const example = selector + ? ` (e.g. "${scopeSelector(DARK_SCOPE_PLACEHOLDER, selector)}")` + : ""; + + if (variant === "dark") { + return `\n\nšŸŒ— **Light & dark:** This block covers the dark variant only. Scope it under your app's dark-mode selector${example} so it doesn't override the light theme. For the light variant, call again with \`variant: "light"\`.`; + } + + return `\n\nšŸŒ— **Light & dark:** This block covers the light variant only. For a dark variant, call again with \`variant: "dark"\` and scope it under your app's dark-mode selector${example}.`; +} + export async function handleCreateComponentTheme( params: CreateComponentThemeParams, ) { @@ -266,7 +291,7 @@ Use \`get_component_design_tokens\` to see all tokens with descriptions.`, // Design system info responseParts.push( - `Design System: ${designSystem.charAt(0).toUpperCase() + designSystem.slice(1)} (${variant})`, + `Design System: ${designSystem.charAt(0).toUpperCase() + designSystem.slice(1)} (${variant === "both" ? "light + dark" : variant})`, ); // Selector info @@ -298,7 +323,10 @@ Use \`get_component_design_tokens\` to see all tokens with descriptions.`, content: [ { type: "text" as const, - text: responseParts.join("\n") + cssWarning, + text: + responseParts.join("\n") + + cssWarning + + getVariantNote(variant, finalSelector), }, ], }; @@ -342,7 +370,7 @@ Use \`get_component_design_tokens\` to see all tokens with descriptions.`, // Design system info responseParts.push( - `Design System: ${designSystem.charAt(0).toUpperCase() + designSystem.slice(1)} (${variant})`, + `Design System: ${designSystem.charAt(0).toUpperCase() + designSystem.slice(1)} (${variant === "both" ? "light + dark" : variant})`, ); // Selector info @@ -375,7 +403,10 @@ Use \`get_component_design_tokens\` to see all tokens with descriptions.`, content: [ { type: "text" as const, - text: responseParts.join("\n") + sassWarning, + text: + responseParts.join("\n") + + sassWarning + + getVariantNote(variant, finalSelector), }, ], }; diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts index 35dcfd4a..30614757 100644 --- a/packages/mcp/src/tools/schemas.ts +++ b/packages/mcp/src/tools/schemas.ts @@ -483,7 +483,10 @@ export const createComponentThemeSchema = z.object({ platform: z.enum(PLATFORMS).describe(PARAM_DESCRIPTIONS.platform), licensed: licensedSchema, designSystem: designSystemSchema.describe(PARAM_DESCRIPTIONS.designSystem), - variant: variantSchema.describe(PARAM_DESCRIPTIONS.variant), + variant: z + .enum([...VARIANTS, "both"]) + .optional() + .describe(PARAM_DESCRIPTIONS.componentThemeVariant), component: z.string().describe(PARAM_DESCRIPTIONS.componentTheme), tokens: z .record(z.string(), tokenValueSchema) diff --git a/packages/mcp/src/utils/sass.ts b/packages/mcp/src/utils/sass.ts index 358dcd3a..92749c60 100644 --- a/packages/mcp/src/utils/sass.ts +++ b/packages/mcp/src/utils/sass.ts @@ -107,6 +107,17 @@ export function generateHeader(description: string): string { export const SASS_USE_INLINE_COMMENT = "// NOTE: @use rules must be at the top of the file. Deduplicate when combining multiple outputs."; +/** Placeholder scope for dark component themes; the app's dark-mode selector isn't known. */ +export const DARK_SCOPE_PLACEHOLDER = ".your-dark-scope"; + +/** Prefix every selector in a comma-separated list with an ancestor scope. */ +export function scopeSelector(scope: string, selector: string): string { + return selector + .split(",") + .map((part) => `${scope} ${part.trim()}`) + .join(", "); +} + /** Markdown assembly note appended after Sass code blocks in handler responses. */ export const SASS_USE_ASSEMBLY_NOTE = "\n> **File placement:** `@use` rules must appear at the very top of the `.scss` file, before any other statements. When combining outputs from multiple tools, keep only one `@use` per module path."; diff --git a/packages/mcp/src/utils/types.ts b/packages/mcp/src/utils/types.ts index 535f1345..08caf1ef 100644 --- a/packages/mcp/src/utils/types.ts +++ b/packages/mcp/src/utils/types.ts @@ -45,6 +45,11 @@ export const DESIGN_SYSTEMS = [ */ export const VARIANTS = ["light", "dark"] as const; +/** + * Variant option for component themes: a single variant, or both light and dark. + */ +export type ComponentThemeVariant = (typeof VARIANTS)[number] | "both"; + /** * Supported elevation presets. */ From 4b6c594314e698eeb2f79bf6576dcfb254a2928a Mon Sep 17 00:00:00 2001 From: Adrian Petrov Date: Tue, 3 Nov 2026 16:42:57 +0200 Subject: [PATCH 3/5] feat: add component tag names to the resolver --- .../knowledge/component-themes.test.ts | 42 ++++++++++++++ .../tools/handlers/component-tokens.test.ts | 14 +++++ .../mcp/src/knowledge/component-themes.ts | 56 +++++++++++++++++++ packages/mcp/src/knowledge/index.ts | 1 + .../mcp/src/tools/handlers/component-theme.ts | 3 +- .../src/tools/handlers/component-tokens.ts | 3 +- packages/mcp/src/tools/handlers/layout.ts | 3 +- 7 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/__tests__/knowledge/component-themes.test.ts b/packages/mcp/src/__tests__/knowledge/component-themes.test.ts index fc2f644e..f1cfd7a5 100644 --- a/packages/mcp/src/__tests__/knowledge/component-themes.test.ts +++ b/packages/mcp/src/__tests__/knowledge/component-themes.test.ts @@ -9,12 +9,54 @@ import { COMPONENT_THEMES, getComponentTheme, getTokenNames, + resolveComponentName, resolveComponentTheme, searchComponents, validateTokens, } from "../../knowledge/component-themes.js"; describe("Component Themes Knowledge Base", () => { + describe("resolveComponentName", () => { + it("keeps component keys as they are", () => { + expect(resolveComponentName("navdrawer")).toBe("navdrawer"); + expect(resolveComponentName("nav-drawer-item")).toBe("nav-drawer-item"); + }); + + it("resolves element selectors with or without the prefix", () => { + expect(resolveComponentName("nav-drawer")).toBe("navdrawer"); + expect(resolveComponentName("igc-nav-drawer")).toBe("navdrawer"); + expect(resolveComponentName(" igx-nav-drawer ")).toBe("navdrawer"); + expect(resolveComponentName("igx-linear-bar")).toBe("progress-linear"); + }); + + it("resolves React (Igr*) and Blazor (Igb*) component names", () => { + expect(resolveComponentName("IgrNavDrawer")).toBe("navdrawer"); + expect(resolveComponentName("IgbNavDrawer")).toBe("navdrawer"); + expect(resolveComponentName("IgrNavDrawerItem")).toBe("nav-drawer-item"); + expect(resolveComponentName("IgbDatePicker")).toBe("date-picker"); + expect(resolveComponentName("IgrLinearProgress")).toBe("progress-linear"); + }); + + it("only resolves tag names with their exact casing and prefix", () => { + for (const name of [ + "IGX-Nav-Drawer", + "IgcAvatar", + "IgxAvatar", + "Igravatar", + "IgrNavdrawer", + "igrNavDrawer", + "igr-nav-drawer", + "navDrawer", + ]) { + expect(resolveComponentName(name)).toBe(name.toLowerCase()); + } + }); + + it("leaves unknown names normalized but unresolved", () => { + expect(resolveComponentName("Unknown-Thing")).toBe("unknown-thing"); + }); + }); + describe("COMPONENT_THEMES", () => { it("should contain expected components", () => { expect(COMPONENT_THEMES).toHaveProperty("avatar"); diff --git a/packages/mcp/src/__tests__/tools/handlers/component-tokens.test.ts b/packages/mcp/src/__tests__/tools/handlers/component-tokens.test.ts index 4e9613d5..be5c622d 100644 --- a/packages/mcp/src/__tests__/tools/handlers/component-tokens.test.ts +++ b/packages/mcp/src/__tests__/tools/handlers/component-tokens.test.ts @@ -6,6 +6,20 @@ import { describe, expect, it } from "vitest"; import { handleGetComponentDesignTokens } from "../../../tools/handlers/component-tokens.js"; describe("handleGetComponentDesignTokens", () => { + it.each([ + "nav-drawer", + "igc-nav-drawer", + "IgrNavDrawer", + "IgbNavDrawer", + ])("resolves %s to the navdrawer component", async (component) => { + const result = await handleGetComponentDesignTokens({ component }); + const text = result.content[0].text; + + expect(text).toContain( + "Implement a theme for the `navdrawer` component using the following guidance.", + ); + }); + it("uses instruction-oriented opening format", async () => { const result = await handleGetComponentDesignTokens({ component: "date-range-picker", diff --git a/packages/mcp/src/knowledge/component-themes.ts b/packages/mcp/src/knowledge/component-themes.ts index 97b612d6..b497b992 100644 --- a/packages/mcp/src/knowledge/component-themes.ts +++ b/packages/mcp/src/knowledge/component-themes.ts @@ -158,6 +158,62 @@ export function validateTokens( }; } +function toPascalCase(name: string): string { + return name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +/** + * Exact component tag names mapped to their component key: + * Angular / Web Components selectors with and without their prefix + * (`igx-nav-drawer`, `igc-nav-drawer`, `nav-drawer`) and React / Blazor + * component names (`IgrNavDrawer`, `IgbNavDrawer`). + */ +const COMPONENTS_BY_TAG = new Map(); + +function addTag(tag: string, key: string) { + if (!COMPONENTS_BY_TAG.has(tag)) { + COMPONENTS_BY_TAG.set(tag, key); + } +} + +for (const [key, metadata] of Object.entries(COMPONENT_METADATA)) { + if (!metadata.selectors) { + addTag(`Igr${toPascalCase(key)}`, key); + addTag(`Igb${toPascalCase(key)}`, key); + } + + for (const [platform, value] of Object.entries(metadata.selectors ?? {})) { + for (const selector of [value].flat()) { + const element = selector?.match(/^ig[cx]-([a-z0-9-]+)$/)?.[1]; + + if (!selector || !element) { + continue; + } + + addTag(selector, key); + addTag(element, key); + + if (platform === "webcomponents") { + addTag(`Igr${toPascalCase(element)}`, key); + addTag(`Igb${toPascalCase(element)}`, key); + } + } + } +} + +export function resolveComponentName(name: string): string { + const normalized = name.toLowerCase().trim(); + + if (COMPONENT_THEMES[normalized] || COMPONENT_METADATA[normalized]) { + return normalized; + } + + return COMPONENTS_BY_TAG.get(name.trim()) ?? normalized; +} + /** * Find components that match a search query. * @param query - Search string to match against component names diff --git a/packages/mcp/src/knowledge/index.ts b/packages/mcp/src/knowledge/index.ts index 43f6bf78..504c350a 100644 --- a/packages/mcp/src/knowledge/index.ts +++ b/packages/mcp/src/knowledge/index.ts @@ -66,6 +66,7 @@ export { type ComponentToken, getComponentTheme, getTokenNames, + resolveComponentName, resolveComponentTheme, searchComponents, validateTokens, diff --git a/packages/mcp/src/tools/handlers/component-theme.ts b/packages/mcp/src/tools/handlers/component-theme.ts index e16c12f7..b253d5df 100644 --- a/packages/mcp/src/tools/handlers/component-theme.ts +++ b/packages/mcp/src/tools/handlers/component-theme.ts @@ -14,6 +14,7 @@ import { getVariants, hasVariants, isComponentAvailable, + resolveComponentName, searchComponents, validateTokens, } from "../../knowledge/index.js"; @@ -93,7 +94,7 @@ export async function handleCreateComponentTheme( designSystem = "material", variant = "light", } = params; - const normalizedComponent = component.toLowerCase().trim(); + const normalizedComponent = resolveComponentName(component); if (!platform) { return { diff --git a/packages/mcp/src/tools/handlers/component-tokens.ts b/packages/mcp/src/tools/handlers/component-tokens.ts index 1ee035d9..e50845a5 100644 --- a/packages/mcp/src/tools/handlers/component-tokens.ts +++ b/packages/mcp/src/tools/handlers/component-tokens.ts @@ -7,6 +7,7 @@ import { getVariants, hasVariants, isCompoundComponent, + resolveComponentName, resolveComponentTheme, searchComponents, } from "../../knowledge/index.js"; @@ -16,7 +17,7 @@ export async function handleGetComponentDesignTokens( params: GetComponentDesignTokensParams, ) { const { component } = params; - const normalizedName = component.toLowerCase().trim(); + const normalizedName = resolveComponentName(component); const resolution = resolveComponentTheme(normalizedName); const theme = resolution?.theme; diff --git a/packages/mcp/src/tools/handlers/layout.ts b/packages/mcp/src/tools/handlers/layout.ts index b5109ea6..46153d79 100644 --- a/packages/mcp/src/tools/handlers/layout.ts +++ b/packages/mcp/src/tools/handlers/layout.ts @@ -6,6 +6,7 @@ import { COMPONENT_METADATA, getComponentPlatformAvailability, getComponentSelector, + resolveComponentName, } from "../../knowledge/index.js"; import type { Platform } from "../../utils/types.js"; import type { @@ -31,7 +32,7 @@ interface ScopeError { } function normalizeComponentName(component?: string): string | null { - return component ? component.toLowerCase().trim() : null; + return component ? resolveComponentName(component) : null; } function buildSelectorList(value: string | string[]): string[] { From 7072b288775219358913f9d6232286744e293dfc Mon Sep 17 00:00:00 2001 From: Adrian Petrov Date: Thu, 5 Nov 2026 19:34:38 +0200 Subject: [PATCH 4/5] fix(mcp): address review feedback --- .../__tests__/contract/tool-output.test.ts | 2 +- .../knowledge/component-themes.test.ts | 42 +-- .../tools/handlers/component-tokens.test.ts | 10 +- .../__tests__/tools/handlers/handlers.test.ts | 52 +-- packages/mcp/src/__tests__/utils/sass.test.ts | 25 ++ packages/mcp/src/generators/css.ts | 51 ++- packages/mcp/src/generators/sass.ts | 63 ++-- .../mcp/src/knowledge/component-metadata.ts | 321 +++++++++++++++--- .../mcp/src/knowledge/component-search.ts | 34 +- .../mcp/src/knowledge/component-themes.ts | 49 +-- packages/mcp/src/tools/descriptions.ts | 11 +- .../mcp/src/tools/handlers/component-theme.ts | 12 +- packages/mcp/src/tools/schemas.ts | 5 +- packages/mcp/src/utils/sass.ts | 19 +- packages/mcp/src/utils/types.ts | 5 - 15 files changed, 438 insertions(+), 263 deletions(-) diff --git a/packages/mcp/src/__tests__/contract/tool-output.test.ts b/packages/mcp/src/__tests__/contract/tool-output.test.ts index fed9d669..d1b9683a 100644 --- a/packages/mcp/src/__tests__/contract/tool-output.test.ts +++ b/packages/mcp/src/__tests__/contract/tool-output.test.ts @@ -240,7 +240,7 @@ describe("tool output compiles", () => { const cases = cartesian({ platform: PRODUCT_PLATFORMS, designSystem: DESIGN_SYSTEMS, - variant: [...VARIANTS, "both"], + variant: VARIANTS, output: OUTPUTS, component: ["flat-button", "avatar"], }); diff --git a/packages/mcp/src/__tests__/knowledge/component-themes.test.ts b/packages/mcp/src/__tests__/knowledge/component-themes.test.ts index f1cfd7a5..f7d866f8 100644 --- a/packages/mcp/src/__tests__/knowledge/component-themes.test.ts +++ b/packages/mcp/src/__tests__/knowledge/component-themes.test.ts @@ -22,34 +22,26 @@ describe("Component Themes Knowledge Base", () => { expect(resolveComponentName("nav-drawer-item")).toBe("nav-drawer-item"); }); - it("resolves element selectors with or without the prefix", () => { - expect(resolveComponentName("nav-drawer")).toBe("navdrawer"); - expect(resolveComponentName("igc-nav-drawer")).toBe("navdrawer"); - expect(resolveComponentName(" igx-nav-drawer ")).toBe("navdrawer"); + it.each([ + "igc-nav-drawer", + "igx-nav-drawer", + "nav-drawer", + "Nav-Drawer", + "IGC-NAV-DRAWER", + "IgrNavDrawer", + "IgbNavDrawer", + ])("resolves %s to navdrawer", (name) => { + expect(resolveComponentName(name)).toBe("navdrawer"); + }); + + it("resolves selectors whose name differs from the key", () => { expect(resolveComponentName("igx-linear-bar")).toBe("progress-linear"); }); - it("resolves React (Igr*) and Blazor (Igb*) component names", () => { - expect(resolveComponentName("IgrNavDrawer")).toBe("navdrawer"); - expect(resolveComponentName("IgbNavDrawer")).toBe("navdrawer"); - expect(resolveComponentName("IgrNavDrawerItem")).toBe("nav-drawer-item"); - expect(resolveComponentName("IgbDatePicker")).toBe("date-picker"); - expect(resolveComponentName("IgrLinearProgress")).toBe("progress-linear"); - }); - - it("only resolves tag names with their exact casing and prefix", () => { - for (const name of [ - "IGX-Nav-Drawer", - "IgcAvatar", - "IgxAvatar", - "Igravatar", - "IgrNavdrawer", - "igrNavDrawer", - "igr-nav-drawer", - "navDrawer", - ]) { - expect(resolveComponentName(name)).toBe(name.toLowerCase()); - } + it("does not resolve React names that aren't listed in metadata", () => { + expect(resolveComponentName("IgrAccordionHeader")).toBe( + "igraccordionheader", + ); }); it("leaves unknown names normalized but unresolved", () => { diff --git a/packages/mcp/src/__tests__/tools/handlers/component-tokens.test.ts b/packages/mcp/src/__tests__/tools/handlers/component-tokens.test.ts index be5c622d..f9c133de 100644 --- a/packages/mcp/src/__tests__/tools/handlers/component-tokens.test.ts +++ b/packages/mcp/src/__tests__/tools/handlers/component-tokens.test.ts @@ -190,17 +190,15 @@ describe("handleGetComponentDesignTokens", () => { ); }); - it("suggests progress-linear for linear progress phrasing", async () => { + it("resolves linear progress phrasing to progress-linear", async () => { const result = await handleGetComponentDesignTokens({ component: "linear progress", }); const text = result.content[0].text; - expect(text).toContain('Component "linear progress" not found.'); - expect(text).toContain("**Similar components:**"); - - const suggestions = text.split("**Similar components:**")[1] ?? ""; - expect(suggestions.trimStart().startsWith("- progress-linear")).toBe(true); + expect(text).toContain( + "Implement a theme for the `progress-linear` component using the following guidance.", + ); }); it("suggests progress components for common typo phrasing", async () => { diff --git a/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts b/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts index 2bd728a6..8516e399 100644 --- a/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts +++ b/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts @@ -474,10 +474,8 @@ describe("handleCreateComponentTheme", () => { }); const text = result.content[0].text; - expect(text).toContain("covers the light variant only"); expect(text).toContain('variant: "dark"'); expect(text).toContain('".your-dark-scope igc-avatar"'); - expect(text).not.toContain('"both"'); }); it("tells agents to scope a dark call and add the light variant", async () => { @@ -490,46 +488,10 @@ describe("handleCreateComponentTheme", () => { }); const text = result.content[0].text; - expect(text).toContain("covers the dark variant only"); expect(text).toContain('".your-dark-scope igx-avatar"'); expect(text).toContain('variant: "light"'); }); - it("returns light and scoped dark CSS blocks for variant both", async () => { - const result = await handleCreateComponentTheme({ - platform: "webcomponents", - component: "avatar", - variant: "both", - tokens: { background: "#ff5722" }, - output: "css", - }); - - expect(result.isError).toBeUndefined(); - const text = result.content[0].text; - expect(text).toContain("(light + dark)"); - expect(text).toMatch(/^igc-avatar \{/m); - expect(text).toMatch(/^\.your-dark-scope igc-avatar \{/m); - expect(text).toContain("Replace `.your-dark-scope`"); - }); - - it("returns light and dark Sass themes with one set of @use rules for variant both", async () => { - const result = await handleCreateComponentTheme({ - platform: "angular", - component: "avatar", - variant: "both", - tokens: { background: "#ff5722" }, - output: "sass", - }); - - expect(result.isError).toBeUndefined(); - const text = result.content[0].text; - expect(text).toContain("$schema: $light-material-schema"); - expect(text).toContain("$schema: $dark-material-schema"); - expect(text).toContain("$custom-avatar-theme-dark"); - expect(text).toContain(".your-dark-scope igx-avatar {"); - expect(text.match(/^@use /gm)).toHaveLength(1); - }); - it("returns error when platform is not provided", async () => { const result = await handleCreateComponentTheme({ component: "avatar", @@ -561,23 +523,17 @@ describe("handleCreateComponentTheme", () => { expect(text).toContain("not supported"); }); - it("suggests progress-linear for linear progress phrasing", async () => { + it("resolves linear progress phrasing to progress-linear", async () => { const result = await handleCreateComponentTheme({ platform: "angular", component: "linear progress", tokens: { - background: "#ff5722", + "fill-color-info": "#ff5722", }, }); - expect(result.isError).toBe(true); - - const text = result.content[0].text; - expect(text).toContain('Component "linear progress" not found.'); - expect(text).toContain("**Similar components:**"); - - const suggestions = text.split("**Similar components:**")[1] ?? ""; - expect(suggestions.trimStart().startsWith("- progress-linear")).toBe(true); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("progress-linear"); }); it("suggests progress components for common typo phrasing", async () => { diff --git a/packages/mcp/src/__tests__/utils/sass.test.ts b/packages/mcp/src/__tests__/utils/sass.test.ts index 16475d57..aa91b75f 100644 --- a/packages/mcp/src/__tests__/utils/sass.test.ts +++ b/packages/mcp/src/__tests__/utils/sass.test.ts @@ -8,9 +8,34 @@ import { generatePaletteCode, generateUseStatement, quoteFontFamily, + scopeSelector, toVariableName, } from "../../utils/sass.js"; +describe("scopeSelector", () => { + it("prefixes a single selector", () => { + expect(scopeSelector(".dark", "igc-avatar")).toBe(".dark igc-avatar"); + }); + + it("prefixes each selector in a list", () => { + expect(scopeSelector(".dark", "igx-avatar, .my-avatar")).toBe( + ".dark igx-avatar, .dark .my-avatar", + ); + }); + + it("keeps commas inside parentheses", () => { + expect(scopeSelector(".dark", ":is(.a, .b) igc-avatar")).toBe( + ".dark :is(.a, .b) igc-avatar", + ); + }); + + it("keeps commas inside attribute selectors", () => { + expect(scopeSelector(".dark", 'igc-avatar[data-label="a,b"]')).toBe( + '.dark igc-avatar[data-label="a,b"]', + ); + }); +}); + describe("toVariableName", () => { it("converts spaces to hyphens", () => { expect(toVariableName("My Theme")).toBe("my-theme"); diff --git a/packages/mcp/src/generators/css.ts b/packages/mcp/src/generators/css.ts index 9b116f8a..6db80662 100644 --- a/packages/mcp/src/generators/css.ts +++ b/packages/mcp/src/generators/css.ts @@ -241,8 +241,7 @@ export async function generateComponentThemeCss( getThemingSelector, SCHEMA_PRESETS, } = await import("../knowledge/index.js"); - const { DARK_SCOPE_PLACEHOLDER, scopeSelector, toVariableName } = - await import("../utils/sass.js"); + const { toVariableName } = await import("../utils/sass.js"); // Validate component exists const theme = getComponentTheme(options.component); @@ -261,8 +260,14 @@ export async function generateComponentThemeCss( ? `$${toVariableName(options.name)}` : `$custom-${themeComponentName}-theme`; - // Build token arguments - the schema is added per variant below - const tokenArgs: string[] = []; + // Get the schema variable based on design system and variant + const schemaVar = + SCHEMA_PRESETS[variant as "light" | "dark"][ + designSystem as "material" | "indigo" | "bootstrap" | "fluent" + ]; + + // Build token arguments - schema comes first + const tokenArgs: string[] = [`$schema: ${schemaVar}`]; for (const [tokenName, value] of Object.entries(options.tokens)) { const stringValue = typeof value === "number" ? String(value) : value; @@ -279,36 +284,20 @@ export async function generateComponentThemeCss( options.selector || (defaultSelectors.length > 0 ? defaultSelectors[0] : options.component); - // "both" adds a dark block with its own variable, scoped under a placeholder - const variants = variant === "both" ? ["light", "dark"] : [variant]; - const blocks = variants.map((v) => { - const isDark = variant === "both" && v === "dark"; - const varName = isDark ? `${themeName}-dark` : themeName; - const varSelector = isDark - ? scopeSelector(DARK_SCOPE_PLACEHOLDER, selector) - : selector; - const schemaVar = - SCHEMA_PRESETS[v as "light" | "dark"][ - designSystem as "material" | "indigo" | "bootstrap" | "fluent" - ]; - - return ` -// Custom ${themeComponentName} theme -${varName}: ${themeFn}( - ${[`$schema: ${schemaVar}`, ...tokenArgs].join(",\n ")} -); - -// Apply the theme to ${varSelector} -${varSelector} { - @include tokens(${varName}); -}`; - }); - // Generate Sass code const sassCode = ` @use 'igniteui-theming/sass/themes' as *; @use 'igniteui-theming/sass/themes/schemas' as *; -${blocks.join("\n")} + +// Custom ${themeComponentName} theme +${themeName}: ${themeFn}( + ${tokenArgs.join(",\n ")} +); + +// Apply the theme to ${selector} +${selector} { + @include tokens(${themeName}); +} `; try { @@ -320,7 +309,7 @@ ${blocks.join("\n")} return { css: result.css, - description: `Generated CSS custom properties for ${themeComponentName} component with ${Object.keys(options.tokens).length} token(s) using ${designSystem} design system (${variant === "both" ? "light + dark" : variant} variant)`, + description: `Generated CSS custom properties for ${themeComponentName} component with ${Object.keys(options.tokens).length} token(s) using ${designSystem} design system (${variant} variant)`, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/mcp/src/generators/sass.ts b/packages/mcp/src/generators/sass.ts index a1320582..aabc0831 100644 --- a/packages/mcp/src/generators/sass.ts +++ b/packages/mcp/src/generators/sass.ts @@ -22,16 +22,13 @@ import { import { generateWebComponentsThemeSass } from "../knowledge/platforms/webcomponents.js"; import { TYPOGRAPHY_PRESETS } from "../knowledge/typography.js"; import { - DARK_SCOPE_PLACEHOLDER, generateHeader, generatePresetImports, generateUseStatement, quoteFontFamily, - scopeSelector, toVariableName, } from "../utils/sass.js"; import type { - ComponentThemeVariant, CreateElevationsInput, CreatePaletteInput, CreateThemeInput, @@ -452,8 +449,8 @@ export interface CreateComponentThemeInput { licensed?: boolean; /** Design system (defaults to 'material') */ designSystem?: DesignSystem; - /** Theme variant - light, dark, or both (defaults to 'light') */ - variant?: ComponentThemeVariant; + /** Theme variant - light or dark (defaults to 'light') */ + variant?: ThemeVariant; /** Component name (e.g., "flat-button", "avatar") */ component: string; /** Token name-value pairs */ @@ -477,7 +474,7 @@ export function generateComponentTheme( } const designSystem: DesignSystem = input.designSystem ?? "material"; - const variant: ComponentThemeVariant = input.variant ?? "light"; + const variant: ThemeVariant = input.variant ?? "light"; const themeFn = theme.themeFunctionName; // For child components, derive variable name from parent to avoid // generating a separate variable that signals "different theme" @@ -487,8 +484,11 @@ export function generateComponentTheme( ? `$${toVariableName(input.name)}` : `$custom-${themeComponentName}-theme`; - // Build token arguments - the schema is added per variant below - const tokenArgs: string[] = []; + // Get the schema variable based on design system and variant + const schemaVar = SCHEMA_PRESETS[variant][designSystem]; + + // Build token arguments - schema comes first + const tokenArgs: string[] = [`$schema: ${schemaVar}`]; for (const [tokenName, value] of Object.entries(input.tokens)) { // Convert value to string if needed const stringValue = typeof value === "number" ? String(value) : value; @@ -514,41 +514,30 @@ export function generateComponentTheme( sections.push(`@use '${SCHEMAS_PATH}' as *;`); } - // "both" adds a dark block with its own variable, scoped under a placeholder - const variants: ThemeVariant[] = - variant === "both" ? ["light", "dark"] : [variant]; - const variables: string[] = []; - - for (const v of variants) { - const isDark = variant === "both" && v === "dark"; - const varName = isDark ? `${themeName}-dark` : themeName; - const varSelector = isDark - ? scopeSelector(DARK_SCOPE_PLACEHOLDER, selector) - : selector; - const args = [`$schema: ${SCHEMA_PRESETS[v][designSystem]}`, ...tokenArgs]; - variables.push(varName); - - sections.push( - "", - `// Custom ${themeComponentName} theme`, - `${varName}: ${themeFn}(`, - ` ${args.join(",\n ")}`, - ");", - ); + sections.push( + "", + `// Custom ${themeComponentName} theme`, + `${themeName}: ${themeFn}(`, + ); - // Apply the theme using tokens mixin (global mode) - sections.push(""); - sections.push(`// Apply the theme to ${varSelector}`); - sections.push(`${varSelector} {`); - sections.push(` @include tokens(${varName});`); - sections.push("}"); + // Add token arguments with proper indentation + if (tokenArgs.length > 0) { + sections.push(` ${tokenArgs.join(",\n ")}`); } + sections.push(");"); + + // Apply the theme using tokens mixin (global mode) + sections.push(""); + sections.push(`// Apply the theme to ${selector}`); + sections.push(`${selector} {`); + sections.push(` @include tokens(${themeName});`); + sections.push("}"); const code = `${sections.join("\n")}\n`; return { code, - description: `Generated custom ${themeComponentName} theme with ${Object.keys(input.tokens).length} token(s) using ${designSystem} design system (${variant === "both" ? "light + dark" : variant} variant)`, - variables, + description: `Generated custom ${themeComponentName} theme with ${Object.keys(input.tokens).length} token(s) using ${designSystem} design system (${variant} variant)`, + variables: [themeName], }; } diff --git a/packages/mcp/src/knowledge/component-metadata.ts b/packages/mcp/src/knowledge/component-metadata.ts index c97b280b..c53c1500 100644 --- a/packages/mcp/src/knowledge/component-metadata.ts +++ b/packages/mcp/src/knowledge/component-metadata.ts @@ -9,6 +9,10 @@ export interface ComponentSelectors { angular: string | string[] | null; /** Web Components selector(s) - can be single string, array, or null if not available on Web Components */ webcomponents: string | string[] | null; + /** React component name (e.g. `IgrNavDrawer`). Used only to resolve component names; React CSS uses `webcomponents`. */ + react?: string; + /** Blazor component name (e.g. `IgbNavDrawer`). Used only to resolve component names; Blazor CSS uses `webcomponents`. */ + blazor?: string; } /** @@ -83,7 +87,12 @@ export interface ComponentMetadata { export const COMPONENT_METADATA: Record = { accordion: { - selectors: { angular: "igx-accordion", webcomponents: "igc-accordion" }, + selectors: { + angular: "igx-accordion", + webcomponents: "igc-accordion", + react: "IgrAccordion", + blazor: "IgbAccordion", + }, }, "accordion-body": { childOf: "accordion", @@ -95,14 +104,29 @@ export const COMPONENT_METADATA: Record = { selectors: { angular: "igx-action-strip", webcomponents: null }, }, avatar: { - selectors: { angular: "igx-avatar", webcomponents: "igc-avatar" }, + selectors: { + angular: "igx-avatar", + webcomponents: "igc-avatar", + react: "IgrAvatar", + blazor: "IgbAvatar", + }, }, badge: { - selectors: { angular: "igx-badge", webcomponents: "igc-badge" }, + selectors: { + angular: "igx-badge", + webcomponents: "igc-badge", + react: "IgrBadge", + blazor: "IgbBadge", + }, tokenAliases: { shadow: "elevation" }, }, banner: { - selectors: { angular: "igx-banner", webcomponents: "igc-banner" }, + selectors: { + angular: "igx-banner", + webcomponents: "igc-banner", + react: "IgrBanner", + blazor: "IgbBanner", + }, compound: { description: "The banner component uses flat-buttons for the actions", relatedThemes: ["flat-button"], @@ -115,14 +139,24 @@ If customizing the banner background, ensure flat-button foreground contrasts ag tokenAliases: { shadow: "elevation" }, }, breadcrumbs: { - selectors: { angular: "igc-breadcrumbs", webcomponents: "igc-breadcrumbs" }, + selectors: { + angular: "igc-breadcrumbs", + webcomponents: "igc-breadcrumbs", + react: "IgrBreadcrumbs", + blazor: "IgbBreadcrumbs", + }, theme: "breadcrumb", }, breadcrumb: { childOf: "breadcrumbs", }, button: { - selectors: { angular: ".igx-button", webcomponents: "igc-button" }, + selectors: { + angular: ".igx-button", + webcomponents: "igc-button", + react: "IgrButton", + blazor: "IgbButton", + }, variants: [ "flat-button", "contained-button", @@ -170,6 +204,8 @@ If customizing the banner background, ensure flat-button foreground contrasts ag selectors: { angular: [".igx-icon-button"], webcomponents: "igc-icon-button", + react: "IgrIconButton", + blazor: "IgbIconButton", }, variants: [ "flat-icon-button", @@ -199,14 +235,26 @@ If customizing the banner background, ensure flat-button foreground contrasts ag selectors: { angular: "igx-buttongroup", webcomponents: "igc-button-group", + react: "IgrButtonGroup", + blazor: "IgbButtonGroup", }, tokenAliases: { shadow: "elevation" }, }, calendar: { - selectors: { angular: "igx-calendar", webcomponents: "igc-calendar" }, + selectors: { + angular: "igx-calendar", + webcomponents: "igc-calendar", + react: "IgrCalendar", + blazor: "IgbCalendar", + }, }, card: { - selectors: { angular: "igx-card", webcomponents: "igc-card" }, + selectors: { + angular: "igx-card", + webcomponents: "igc-card", + react: "IgrCard", + blazor: "IgbCard", + }, tokenAliases: { "resting-shadow": "resting-elevation", "hover-shadow": "hover-elevation", @@ -230,11 +278,21 @@ If customizing the banner background, ensure flat-button foreground contrasts ag childOf: "card", }, carousel: { - selectors: { angular: "igx-carousel", webcomponents: "igc-carousel" }, + selectors: { + angular: "igx-carousel", + webcomponents: "igc-carousel", + react: "IgrCarousel", + blazor: "IgbCarousel", + }, tokenAliases: { "button-shadow": "button-elevation" }, }, chat: { - selectors: { angular: "igc-chat", webcomponents: "igc-chat" }, + selectors: { + angular: "igc-chat", + webcomponents: "igc-chat", + react: "IgrChat", + blazor: "IgbChat", + }, compound: { description: "The chat component uses a flat-icon-button, contained-icon-button, and a textarea internally.", @@ -248,10 +306,20 @@ If customizing the banner background, ensure flat-button foreground contrasts ag }, }, checkbox: { - selectors: { angular: "igx-checkbox", webcomponents: "igc-checkbox" }, + selectors: { + angular: "igx-checkbox", + webcomponents: "igc-checkbox", + react: "IgrCheckbox", + blazor: "IgbCheckbox", + }, }, chip: { - selectors: { angular: "igx-chip", webcomponents: "igc-chip" }, + selectors: { + angular: "igx-chip", + webcomponents: "igc-chip", + react: "IgrChip", + blazor: "IgbChip", + }, tokenAliases: { "ghost-shadow": "ghost-elevation" }, }, "column-actions": { @@ -274,6 +342,8 @@ If customizing the banner background, ensure flat-button foreground contrasts ag selectors: { angular: "igc-color-picker", webcomponents: "igc-color-picker", + react: "IgrColorPicker", + blazor: "IgbColorPicker", }, tokenAliases: { "picker-shadow": "picker-elevation", @@ -282,7 +352,12 @@ If customizing the banner background, ensure flat-button foreground contrasts ag aliases: ["colour picker"], }, combo: { - selectors: { angular: "igx-combo", webcomponents: "igc-combo" }, + selectors: { + angular: "igx-combo", + webcomponents: "igc-combo", + react: "IgrCombo", + blazor: "IgbCombo", + }, aliases: ["combobox", "autocomplete"], compound: { description: @@ -334,7 +409,12 @@ If customizing the banner background, ensure flat-button foreground contrasts ag }, }, "date-picker": { - selectors: { angular: "igx-date-picker", webcomponents: "igc-date-picker" }, + selectors: { + angular: "igx-date-picker", + webcomponents: "igc-date-picker", + react: "IgrDatePicker", + blazor: "IgbDatePicker", + }, compound: { description: "The date-picker combines input, calendar, and flat-button components.", @@ -353,6 +433,8 @@ If customizing the banner background, ensure flat-button foreground contrasts ag selectors: { angular: "igx-date-range-picker", webcomponents: "igc-date-range-picker", + react: "IgrDateRangePicker", + blazor: "IgbDateRangePicker", }, compound: { description: @@ -377,11 +459,21 @@ If customizing the banner background, ensure flat-button foreground contrasts ag theme: "input-group", }, "date-time-input": { - selectors: { angular: null, webcomponents: "igc-date-time-input" }, + selectors: { + angular: null, + webcomponents: "igc-date-time-input", + react: "IgrDateTimeInput", + blazor: "IgbDateTimeInput", + }, aliases: ["datetime input", "date time input"], }, dialog: { - selectors: { angular: ".igx-dialog", webcomponents: "igc-dialog" }, + selectors: { + angular: ".igx-dialog", + webcomponents: "igc-dialog", + react: "IgrDialog", + blazor: "IgbDialog", + }, tokenAliases: { shadow: "elevation" }, aliases: ["modal", "popup"], compound: { @@ -398,15 +490,27 @@ If customizing the dialog background, ensure flat-button foreground contrasts ag }, }, divider: { - selectors: { angular: "igx-divider", webcomponents: "igc-divider" }, + selectors: { + angular: "igx-divider", + webcomponents: "igc-divider", + react: "IgrDivider", + blazor: "IgbDivider", + }, }, "dock-manager": { - selectors: { angular: "igc-dockmanager", webcomponents: "igc-dockmanager" }, + selectors: { + angular: "igc-dockmanager", + webcomponents: "igc-dockmanager", + react: "IgrDockManager", + blazor: "IgbDockManager", + }, }, "drop-down": { selectors: { angular: ".igx-drop-down__list", webcomponents: "igc-dropdown", + react: "IgrDropdown", + blazor: "IgbDropdown", }, tokenAliases: { shadow: "elevation" }, aliases: ["dropdown menu"], @@ -418,6 +522,8 @@ If customizing the dialog background, ensure flat-button foreground contrasts ag selectors: { angular: "igx-expansion-panel", webcomponents: "igc-expansion-panel", + react: "IgrExpansionPanel", + blazor: "IgbExpansionPanel", }, }, "expansion-panel-body": { @@ -430,6 +536,7 @@ If customizing the dialog background, ensure flat-button foreground contrasts ag selectors: { angular: 'igx-input-group[class~="igx-input-group--file"]', webcomponents: "igc-file-input", + react: "IgrFileInput", }, compound: { description: "The file-input uses an input-group for the input field.", @@ -439,7 +546,12 @@ Both themes should share the same visual treatment as the file-input wrapper.`, }, }, grid: { - selectors: { angular: "igx-grid", webcomponents: "igc-grid" }, + selectors: { + angular: "igx-grid", + webcomponents: "igc-grid", + react: "IgrGrid", + blazor: "IgbGrid", + }, tokenAliases: { "grid-shadow": "grid-elevation", "drag-shadow": "drag-elevation", @@ -447,7 +559,12 @@ Both themes should share the same visual treatment as the file-input wrapper.`, compound: GRID_COMPOUND_INFO, }, "tree-grid": { - selectors: { angular: "igx-tree-grid", webcomponents: "igc-tree-grid" }, + selectors: { + angular: "igx-tree-grid", + webcomponents: "igc-tree-grid", + react: "IgrTreeGrid", + blazor: "IgbTreeGrid", + }, theme: "grid", compound: GRID_COMPOUND_INFO, }, @@ -455,17 +572,29 @@ Both themes should share the same visual treatment as the file-input wrapper.`, selectors: { angular: "igx-hierarchical-grid", webcomponents: "igc-hierarchical-grid", + react: "IgrHierarchicalGrid", + blazor: "IgbHierarchicalGrid", }, theme: "grid", compound: GRID_COMPOUND_INFO, }, "pivot-grid": { - selectors: { angular: "igx-pivot-grid", webcomponents: "igc-pivot-grid" }, + selectors: { + angular: "igx-pivot-grid", + webcomponents: "igc-pivot-grid", + react: "IgrPivotGrid", + blazor: "IgbPivotGrid", + }, theme: "grid", compound: GRID_COMPOUND_INFO, }, "grid-lite": { - selectors: { angular: "igx-grid-lite", webcomponents: "igc-grid-lite" }, + selectors: { + angular: "igx-grid-lite", + webcomponents: "igc-grid-lite", + react: "IgrGridLite", + blazor: "IgbGridLite", + }, theme: "grid", compound: { description: @@ -494,13 +623,28 @@ Both themes should share the same visual treatment as the file-input wrapper.`, }, }, highlight: { - selectors: { angular: "igx-highlight", webcomponents: "igc-highlight" }, + selectors: { + angular: "igx-highlight", + webcomponents: "igc-highlight", + react: "IgrHighlight", + blazor: "IgbHighlight", + }, }, icon: { - selectors: { angular: "igx-icon", webcomponents: "igc-icon" }, + selectors: { + angular: "igx-icon", + webcomponents: "igc-icon", + react: "IgrIcon", + blazor: "IgbIcon", + }, }, "input-group": { - selectors: { angular: "igx-input-group", webcomponents: "igc-input" }, + selectors: { + angular: "igx-input-group", + webcomponents: "igc-input", + react: "IgrInput", + blazor: "IgbInput", + }, tokenAliases: { "search-resting-shadow": "search-resting-elevation", "search-hover-shadow": "search-hover-elevation", @@ -511,7 +655,12 @@ Both themes should share the same visual treatment as the file-input wrapper.`, selectors: { angular: "[igxLabel]", webcomponents: "igc-label" }, }, list: { - selectors: { angular: "igx-list", webcomponents: "igc-list" }, + selectors: { + angular: "igx-list", + webcomponents: "igc-list", + react: "IgrList", + blazor: "IgbList", + }, }, "list-header": { childOf: "list", @@ -520,7 +669,12 @@ Both themes should share the same visual treatment as the file-input wrapper.`, childOf: "list", }, navbar: { - selectors: { angular: "igx-navbar", webcomponents: "igc-navbar" }, + selectors: { + angular: "igx-navbar", + webcomponents: "igc-navbar", + react: "IgrNavbar", + blazor: "IgbNavbar", + }, tokenAliases: { shadow: "elevation" }, compound: { description: @@ -538,7 +692,12 @@ Both themes should share the same visual treatment as the file-input wrapper.`, }, }, navdrawer: { - selectors: { angular: "igx-nav-drawer", webcomponents: "igc-nav-drawer" }, + selectors: { + angular: "igx-nav-drawer", + webcomponents: "igc-nav-drawer", + react: "IgrNavDrawer", + blazor: "IgbNavDrawer", + }, tokenAliases: { shadow: "elevation" }, aliases: [ "drawer", @@ -549,8 +708,6 @@ Both themes should share the same visual treatment as the file-input wrapper.`, "sidebar", "navigation drawer", "navigation panel", - "nav-drawer", - "nav drawer", ], }, "nav-drawer-item": { @@ -589,6 +746,8 @@ and descriptions from get_component_design_tokens for each child to guide value selectors: { angular: "igx-circular-bar", webcomponents: "igc-circular-progress", + react: "IgrCircularProgress", + blazor: "IgbCircularProgress", }, aliases: ["spinner", "circular loader", "loading spinner"], }, @@ -596,6 +755,8 @@ and descriptions from get_component_design_tokens for each child to guide value selectors: { angular: "igx-linear-bar", webcomponents: "igc-linear-progress", + react: "IgrLinearProgress", + blazor: "IgbLinearProgress", }, aliases: ["progress-bar", "loading bar", "linear loader"], }, @@ -620,24 +781,49 @@ chips for displaying conditions, and buttons/button-groups for adding and groupi }, }, "qr-code": { - selectors: { angular: "igc-qr-code", webcomponents: "igc-qr-code" }, + selectors: { + angular: "igc-qr-code", + webcomponents: "igc-qr-code", + react: "IgrQrCode", + blazor: "IgbQrCode", + }, aliases: ["qr", "qrcode"], }, radio: { - selectors: { angular: "igx-radio", webcomponents: "igc-radio" }, + selectors: { + angular: "igx-radio", + webcomponents: "igc-radio", + react: "IgrRadio", + blazor: "IgbRadio", + }, }, rating: { - selectors: { angular: "igc-rating", webcomponents: "igc-rating" }, + selectors: { + angular: "igc-rating", + webcomponents: "igc-rating", + react: "IgrRating", + blazor: "IgbRating", + }, aliases: ["star rating"], }, ripple: { - selectors: { angular: "igx-ripple", webcomponents: "igc-ripple" }, + selectors: { + angular: "igx-ripple", + webcomponents: "igc-ripple", + react: "IgrRipple", + blazor: "IgbRipple", + }, }, scrollbar: { selectors: { angular: ".ig-scrollbar", webcomponents: ".ig-scrollbar" }, }, select: { - selectors: { angular: "igx-select", webcomponents: "igc-select" }, + selectors: { + angular: "igx-select", + webcomponents: "igc-select", + react: "IgrSelect", + blazor: "IgbSelect", + }, aliases: ["select box"], compound: { description: @@ -658,23 +844,48 @@ The drop-down background should match the select surface intent.`, }, }, slider: { - selectors: { angular: "igx-slider", webcomponents: "igc-slider" }, + selectors: { + angular: "igx-slider", + webcomponents: "igc-slider", + react: "IgrSlider", + blazor: "IgbSlider", + }, }, snackbar: { - selectors: { angular: "igx-snackbar", webcomponents: "igc-snackbar" }, + selectors: { + angular: "igx-snackbar", + webcomponents: "igc-snackbar", + react: "IgrSnackbar", + blazor: "IgbSnackbar", + }, tokenAliases: { shadow: "elevation" }, }, splitter: { - selectors: { angular: "igx-splitter", webcomponents: "igc-splitter" }, + selectors: { + angular: "igx-splitter", + webcomponents: "igc-splitter", + react: "IgrSplitter", + blazor: "IgbSplitter", + }, }, step: { childOf: "stepper", }, stepper: { - selectors: { angular: "igx-stepper", webcomponents: "igc-stepper" }, + selectors: { + angular: "igx-stepper", + webcomponents: "igc-stepper", + react: "IgrStepper", + blazor: "IgbStepper", + }, }, switch: { - selectors: { angular: "igx-switch", webcomponents: "igc-switch" }, + selectors: { + angular: "igx-switch", + webcomponents: "igc-switch", + react: "IgrSwitch", + blazor: "IgbSwitch", + }, tokenAliases: { "resting-shadow": "resting-elevation", "hover-shadow": "hover-elevation", @@ -683,7 +894,12 @@ The drop-down background should match the select surface intent.`, aliases: ["toggle"], }, tabs: { - selectors: { angular: "igx-tabs", webcomponents: "igc-tabs" }, + selectors: { + angular: "igx-tabs", + webcomponents: "igc-tabs", + react: "IgrTabs", + blazor: "IgbTabs", + }, }, "tab-item": { childOf: "tabs", @@ -705,19 +921,36 @@ The input-group text color should coordinate with the time-picker header.`, selectors: { angular: ".igx-input-group--textarea-group", webcomponents: "igc-textarea", + react: "IgrTextarea", + blazor: "IgbTextarea", }, theme: "input-group", }, toast: { - selectors: { angular: "igx-toast", webcomponents: "igc-toast" }, + selectors: { + angular: "igx-toast", + webcomponents: "igc-toast", + react: "IgrToast", + blazor: "IgbToast", + }, tokenAliases: { shadow: "elevation" }, }, tooltip: { - selectors: { angular: ".igx-tooltip", webcomponents: "igc-tooltip" }, + selectors: { + angular: ".igx-tooltip", + webcomponents: "igc-tooltip", + react: "IgrTooltip", + blazor: "IgbTooltip", + }, tokenAliases: { shadow: "elevation" }, }, tree: { - selectors: { angular: "igx-tree-node", webcomponents: "igc-tree" }, + selectors: { + angular: "igx-tree-node", + webcomponents: "igc-tree", + react: "IgrTree", + blazor: "IgbTree", + }, }, watermark: { selectors: { diff --git a/packages/mcp/src/knowledge/component-search.ts b/packages/mcp/src/knowledge/component-search.ts index 46e5bb2e..05027304 100644 --- a/packages/mcp/src/knowledge/component-search.ts +++ b/packages/mcp/src/knowledge/component-search.ts @@ -22,6 +22,8 @@ interface NormalizedSearchTerm { interface ComponentSearchEntry { name: string; signals: NormalizedSearchTerm[]; + /** Compact forms of the component key and its selectors (not aliases), used by `resolve`. */ + exactKeys: Set; } /** Options accepted by {@link createComponentSearcher}. */ @@ -30,9 +32,11 @@ export interface CreateComponentSearcherOptions { metadata: Record; } -/** Pre-built search index with a single `search` method. */ +/** Pre-built search index with `search` and `resolve` methods. */ export interface ComponentSearcher { search(query: string): string[]; + /** The component whose key or selector matches the query exactly, if exactly one does. */ + resolve(query: string): string | undefined; } /* @@ -100,7 +104,12 @@ function getSelectorSearchSignals(selectors?: ComponentSelectors): string[] { return []; } - const values = [selectors.angular, selectors.webcomponents]; + const values = [ + selectors.angular, + selectors.webcomponents, + selectors.react, + selectors.blazor, + ]; const signals: string[] = []; for (const value of values) { @@ -149,8 +158,13 @@ function buildComponentSearchIndex( const signals = getComponentSearchSignals(name, metadataByName) .map(normalizeSearchTerm) .filter((signal): signal is NormalizedSearchTerm => !!signal); + const exactKeys = new Set( + [name, ...getSelectorSearchSignals(metadataByName[name]?.selectors)] + .map((term) => normalizeSearchTerm(term)?.compact) + .filter((key): key is string => !!key), + ); - return { name, signals }; + return { name, signals, exactKeys }; }); } @@ -353,5 +367,19 @@ export function createComponentSearcher( .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)) .map((match) => match.name); }, + + resolve(query: string): string | undefined { + const normalizedQuery = normalizeSearchTerm(query); + + if (!normalizedQuery) { + return undefined; + } + + const matches = componentSearchIndex.filter((entry) => + entry.exactKeys.has(normalizedQuery.compact), + ); + + return matches.length === 1 ? matches[0].name : undefined; + }, }; } diff --git a/packages/mcp/src/knowledge/component-themes.ts b/packages/mcp/src/knowledge/component-themes.ts index b497b992..b26eea8e 100644 --- a/packages/mcp/src/knowledge/component-themes.ts +++ b/packages/mcp/src/knowledge/component-themes.ts @@ -158,52 +158,11 @@ export function validateTokens( }; } -function toPascalCase(name: string): string { - return name - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); -} - /** - * Exact component tag names mapped to their component key: - * Angular / Web Components selectors with and without their prefix - * (`igx-nav-drawer`, `igc-nav-drawer`, `nav-drawer`) and React / Blazor - * component names (`IgrNavDrawer`, `IgbNavDrawer`). + * Normalize a component name and resolve it to its component key. + * Accepts the key itself or any of its selectors / React / Blazor names + * (e.g. "nav-drawer", "igc-nav-drawer", "IgrNavDrawer" → "navdrawer"). */ -const COMPONENTS_BY_TAG = new Map(); - -function addTag(tag: string, key: string) { - if (!COMPONENTS_BY_TAG.has(tag)) { - COMPONENTS_BY_TAG.set(tag, key); - } -} - -for (const [key, metadata] of Object.entries(COMPONENT_METADATA)) { - if (!metadata.selectors) { - addTag(`Igr${toPascalCase(key)}`, key); - addTag(`Igb${toPascalCase(key)}`, key); - } - - for (const [platform, value] of Object.entries(metadata.selectors ?? {})) { - for (const selector of [value].flat()) { - const element = selector?.match(/^ig[cx]-([a-z0-9-]+)$/)?.[1]; - - if (!selector || !element) { - continue; - } - - addTag(selector, key); - addTag(element, key); - - if (platform === "webcomponents") { - addTag(`Igr${toPascalCase(element)}`, key); - addTag(`Igb${toPascalCase(element)}`, key); - } - } - } -} - export function resolveComponentName(name: string): string { const normalized = name.toLowerCase().trim(); @@ -211,7 +170,7 @@ export function resolveComponentName(name: string): string { return normalized; } - return COMPONENTS_BY_TAG.get(name.trim()) ?? normalized; + return componentSearcher.resolve(name) ?? normalized; } /** diff --git a/packages/mcp/src/tools/descriptions.ts b/packages/mcp/src/tools/descriptions.ts index ec66ded2..c293cac2 100644 --- a/packages/mcp/src/tools/descriptions.ts +++ b/packages/mcp/src/tools/descriptions.ts @@ -857,7 +857,7 @@ export const TOOL_DESCRIPTIONS = { DESIGN SYSTEM & VARIANT: - designSystem: Choose "material" (default), "bootstrap", "fluent", or "indigo" - - variant: Choose "light" (default), "dark", or "both" + - variant: Choose "light" (default) or "dark" - The correct schema (e.g., $light-bootstrap-schema, $dark-material-schema) is automatically selected and passed to the component theme function - The correct output: Choose output format based on the target file type (Sass vs CSS) @@ -866,8 +866,15 @@ export const TOOL_DESCRIPTIONS = { LIGHT & DARK: - Each call produces one variant. If the app supports light and dark, call this tool for each variant — do not ship only the light one. + - The dark call usually needs different token values than the light one (e.g. colors + that work on a dark surface). Do not repeat the light tokens unchanged. - Scope the dark call under the app's dark-mode selector so it doesn't override the light theme (e.g. ".your-dark-scope igc-avatar"). + - Put that dark-mode selector on a root element (\`html\`, \`body\`, or the app's theme + root), not on an app container. On Angular, overlay-hosted components + (\`.igx-dialog\`, \`.igx-drop-down__list\`, \`.igx-tooltip\`, \`.igx-overlay__content\`) + are moved to the overlay outlet under \`body\`, so a scope on an app container + won't match them. - The response includes a note with the scoped selector for the other variant. TOKEN VALIDATION: @@ -1283,8 +1290,6 @@ Important: Gray progression is INVERTED for dark themes (50=darkest, 900=lightes // --------------------------------------------------------------------------- // Component theming parameters // --------------------------------------------------------------------------- - componentThemeVariant: `Theme variant: "light" (default), "dark", or "both". "both" returns a light block on the default selector and a dark block scoped under the \`.your-dark-scope\` placeholder, which must be replaced with the app's dark-mode selector.`, - component: `Component name to get design tokens for (e.g., "button", "avatar", "grid"). Use exact names like "flat-button" for button variants. Child sub-component names like "list-item", "card-header", "accordion-header", "tab-item", "step" are also supported — they resolve to the parent component's theme. Call this tool to discover available tokens BEFORE using create_component_theme.`, componentTheme: `Component name to theme (e.g., "button", "avatar", "flat-button", "grid"). Must match a valid component from get_component_design_tokens. For button/icon-button variants, use specific names like "flat-button", "contained-button", "outlined-button", "fab-button". Child sub-component names (e.g., "list-item", "card-header") are supported and automatically resolve to the parent theme with the parent's selector and variable name.`, diff --git a/packages/mcp/src/tools/handlers/component-theme.ts b/packages/mcp/src/tools/handlers/component-theme.ts index b253d5df..99bfc1eb 100644 --- a/packages/mcp/src/tools/handlers/component-theme.ts +++ b/packages/mcp/src/tools/handlers/component-theme.ts @@ -66,19 +66,15 @@ function getComposedTokenWarning( * so the dark block has to be scoped by the agent. */ function getVariantNote(variant: string, selector: string | undefined): string { - if (variant === "both") { - return `\n\nšŸŒ— **Light & dark:** Replace \`${DARK_SCOPE_PLACEHOLDER}\` with your app's dark-mode selector.`; - } - const example = selector ? ` (e.g. "${scopeSelector(DARK_SCOPE_PLACEHOLDER, selector)}")` : ""; if (variant === "dark") { - return `\n\nšŸŒ— **Light & dark:** This block covers the dark variant only. Scope it under your app's dark-mode selector${example} so it doesn't override the light theme. For the light variant, call again with \`variant: "light"\`.`; + return `\n\nšŸŒ— **Light & dark:** This block covers the dark variant only. Scope it under your app's dark-mode selector${example} so it doesn't override the light theme. Put that selector on a root element (\`html\` or \`body\`) so overlay content is included. For the light variant, call again with \`variant: "light"\` and token values for a light surface.`; } - return `\n\nšŸŒ— **Light & dark:** This block covers the light variant only. For a dark variant, call again with \`variant: "dark"\` and scope it under your app's dark-mode selector${example}.`; + return `\n\nšŸŒ— **Light & dark:** This block covers the light variant only. For a dark variant, call again with \`variant: "dark"\` and token values for a dark surface (usually different from these), and scope it under your app's dark-mode selector${example}.`; } export async function handleCreateComponentTheme( @@ -292,7 +288,7 @@ Use \`get_component_design_tokens\` to see all tokens with descriptions.`, // Design system info responseParts.push( - `Design System: ${designSystem.charAt(0).toUpperCase() + designSystem.slice(1)} (${variant === "both" ? "light + dark" : variant})`, + `Design System: ${designSystem.charAt(0).toUpperCase() + designSystem.slice(1)} (${variant})`, ); // Selector info @@ -371,7 +367,7 @@ Use \`get_component_design_tokens\` to see all tokens with descriptions.`, // Design system info responseParts.push( - `Design System: ${designSystem.charAt(0).toUpperCase() + designSystem.slice(1)} (${variant === "both" ? "light + dark" : variant})`, + `Design System: ${designSystem.charAt(0).toUpperCase() + designSystem.slice(1)} (${variant})`, ); // Selector info diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts index 30614757..35dcfd4a 100644 --- a/packages/mcp/src/tools/schemas.ts +++ b/packages/mcp/src/tools/schemas.ts @@ -483,10 +483,7 @@ export const createComponentThemeSchema = z.object({ platform: z.enum(PLATFORMS).describe(PARAM_DESCRIPTIONS.platform), licensed: licensedSchema, designSystem: designSystemSchema.describe(PARAM_DESCRIPTIONS.designSystem), - variant: z - .enum([...VARIANTS, "both"]) - .optional() - .describe(PARAM_DESCRIPTIONS.componentThemeVariant), + variant: variantSchema.describe(PARAM_DESCRIPTIONS.variant), component: z.string().describe(PARAM_DESCRIPTIONS.componentTheme), tokens: z .record(z.string(), tokenValueSchema) diff --git a/packages/mcp/src/utils/sass.ts b/packages/mcp/src/utils/sass.ts index 92749c60..74b54483 100644 --- a/packages/mcp/src/utils/sass.ts +++ b/packages/mcp/src/utils/sass.ts @@ -110,10 +110,23 @@ export const SASS_USE_INLINE_COMMENT = /** Placeholder scope for dark component themes; the app's dark-mode selector isn't known. */ export const DARK_SCOPE_PLACEHOLDER = ".your-dark-scope"; -/** Prefix every selector in a comma-separated list with an ancestor scope. */ +/** + * Prefix every selector in a comma-separated list with an ancestor scope. + * Only top-level commas split the list; commas inside `()` and `[]` are kept. + */ export function scopeSelector(scope: string, selector: string): string { - return selector - .split(",") + let depth = 0; + // Mark top-level commas, then split on the marks + const marked = [...selector] + .map((char) => { + if ("([".includes(char)) depth++; + if (")]".includes(char)) depth--; + return char === "," && depth === 0 ? "\0" : char; + }) + .join(""); + + return marked + .split("\0") .map((part) => `${scope} ${part.trim()}`) .join(", "); } diff --git a/packages/mcp/src/utils/types.ts b/packages/mcp/src/utils/types.ts index 08caf1ef..535f1345 100644 --- a/packages/mcp/src/utils/types.ts +++ b/packages/mcp/src/utils/types.ts @@ -45,11 +45,6 @@ export const DESIGN_SYSTEMS = [ */ export const VARIANTS = ["light", "dark"] as const; -/** - * Variant option for component themes: a single variant, or both light and dark. - */ -export type ComponentThemeVariant = (typeof VARIANTS)[number] | "both"; - /** * Supported elevation presets. */ From a3e912711f5e16e44861778a4cda687849e7dff8 Mon Sep 17 00:00:00 2001 From: Adrian Petrov Date: Fri, 6 Nov 2026 14:37:30 +0200 Subject: [PATCH 5/5] feat(mcp-metadata): add missing edntries for react and blazor --- packages/mcp/src/knowledge/component-metadata.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/knowledge/component-metadata.ts b/packages/mcp/src/knowledge/component-metadata.ts index c53c1500..300ca378 100644 --- a/packages/mcp/src/knowledge/component-metadata.ts +++ b/packages/mcp/src/knowledge/component-metadata.ts @@ -620,6 +620,8 @@ Both themes should share the same visual treatment as the file-input wrapper.`, selectors: { angular: "igx-grid-toolbar", webcomponents: "igc-grid-toolbar", + react: "IgrGridToolbar", + blazor: "IgbGridToolbar", }, }, highlight: { @@ -717,7 +719,12 @@ Both themes should share the same visual treatment as the file-input wrapper.`, selectors: { angular: ".igx-overlay__content", webcomponents: null }, }, paginator: { - selectors: { angular: "igx-paginator", webcomponents: "igc-paginator" }, + selectors: { + angular: "igx-paginator", + webcomponents: "igc-paginator", + react: "IgrPaginator", + blazor: "IgbPaginator", + }, aliases: ["pagination", "pager"], compound: { description: @@ -731,6 +738,8 @@ Both themes should share the same visual treatment as the file-input wrapper.`, selectors: { angular: "igx-pivot-data-selector", webcomponents: "igc-pivot-data-selector", + react: "IgrPivotDataSelector", + blazor: "IgbPivotDataSelector", }, compound: { description: