diff --git a/packages/mcp/src/__tests__/knowledge/component-themes.test.ts b/packages/mcp/src/__tests__/knowledge/component-themes.test.ts index fc2f644e..f7d866f8 100644 --- a/packages/mcp/src/__tests__/knowledge/component-themes.test.ts +++ b/packages/mcp/src/__tests__/knowledge/component-themes.test.ts @@ -9,12 +9,46 @@ 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.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("does not resolve React names that aren't listed in metadata", () => { + expect(resolveComponentName("IgrAccordionHeader")).toBe( + "igraccordionheader", + ); + }); + + 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..f9c133de 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", @@ -176,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 dd92bfd4..8516e399 100644 --- a/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts +++ b/packages/mcp/src/__tests__/tools/handlers/handlers.test.ts @@ -465,6 +465,33 @@ 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('variant: "dark"'); + expect(text).toContain('".your-dark-scope igc-avatar"'); + }); + + 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('".your-dark-scope igx-avatar"'); + expect(text).toContain('variant: "light"'); + }); + it("returns error when platform is not provided", async () => { const result = await handleCreateComponentTheme({ component: "avatar", @@ -496,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/knowledge/component-metadata.ts b/packages/mcp/src/knowledge/component-metadata.ts index 693862d2..300ca378 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: @@ -491,16 +620,33 @@ 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: { - 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 +657,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 +671,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,13 +694,20 @@ 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", "side drawer", "side nav", + "side panel", "sidenav", + "sidebar", "navigation drawer", "navigation panel", ], @@ -556,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: @@ -570,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: @@ -585,6 +755,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"], }, @@ -592,6 +764,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"], }, @@ -616,24 +790,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: @@ -654,23 +853,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", @@ -679,7 +903,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", @@ -701,19 +930,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 97b612d6..b26eea8e 100644 --- a/packages/mcp/src/knowledge/component-themes.ts +++ b/packages/mcp/src/knowledge/component-themes.ts @@ -158,6 +158,21 @@ export function validateTokens( }; } +/** + * 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"). + */ +export function resolveComponentName(name: string): string { + const normalized = name.toLowerCase().trim(); + + if (COMPONENT_THEMES[normalized] || COMPONENT_METADATA[normalized]) { + return normalized; + } + + return componentSearcher.resolve(name) ?? 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/descriptions.ts b/packages/mcp/src/tools/descriptions.ts index 61c3a4f6..c293cac2 100644 --- a/packages/mcp/src/tools/descriptions.ts +++ b/packages/mcp/src/tools/descriptions.ts @@ -863,6 +863,20 @@ export const TOOL_DESCRIPTIONS = { - 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. + - 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: - 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 +933,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 diff --git a/packages/mcp/src/tools/handlers/component-theme.ts b/packages/mcp/src/tools/handlers/component-theme.ts index 80520f38..99bfc1eb 100644 --- a/packages/mcp/src/tools/handlers/component-theme.ts +++ b/packages/mcp/src/tools/handlers/component-theme.ts @@ -14,11 +14,16 @@ import { getVariants, hasVariants, isComponentAvailable, + resolveComponentName, searchComponents, 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 +60,23 @@ 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 { + 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. 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 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( params: CreateComponentThemeParams, ) { @@ -68,7 +90,7 @@ export async function handleCreateComponentTheme( designSystem = "material", variant = "light", } = params; - const normalizedComponent = component.toLowerCase().trim(); + const normalizedComponent = resolveComponentName(component); if (!platform) { return { @@ -298,7 +320,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), }, ], }; @@ -375,7 +400,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/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[] { diff --git a/packages/mcp/src/utils/sass.ts b/packages/mcp/src/utils/sass.ts index 358dcd3a..74b54483 100644 --- a/packages/mcp/src/utils/sass.ts +++ b/packages/mcp/src/utils/sass.ts @@ -107,6 +107,30 @@ 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. + * Only top-level commas split the list; commas inside `()` and `[]` are kept. + */ +export function scopeSelector(scope: string, selector: string): string { + 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(", "); +} + /** 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.";