diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fa335797..f5f41af95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.4.3) + '@nvidia-elements/code': + specifier: workspace:* + version: link:../code '@nvidia-elements/lint': specifier: workspace:^ version: link:../lint @@ -22564,7 +22567,7 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 source-map-js: 1.2.1 diff --git a/projects/cli/package.json b/projects/cli/package.json index 6ef2888a7..c05da053f 100644 --- a/projects/cli/package.json +++ b/projects/cli/package.json @@ -34,7 +34,7 @@ "dist/**/*.js" ], "scripts": { - "dev": "pnpm run nve:install && pnpm dlx @modelcontextprotocol/inspector@0.21.2 node ./dist/index.js mcp", + "dev": "pnpm run nve:install && npx @modelcontextprotocol/inspector@0.21.2 node ./dist/index.js mcp", "ci": "wireit", "build": "wireit", "lint": "wireit", @@ -47,22 +47,23 @@ "nve:uninstall:node": "pnpm uninstall --global @nvidia-elements/cli" }, "dependencies": { - "@nvidia-elements/lint": "workspace:^", "@inquirer/prompts": "8.4.2", "@modelcontextprotocol/sdk": "1.29.0", + "@nvidia-elements/code": "workspace:*", + "@nvidia-elements/lint": "workspace:^", "adm-zip": "0.5.17", "archiver": "7.0.1", "marked": "18.0.3", "marked-terminal": "7.3.0", + "open": "11.0.0", "ora": "9.4.0", "publint": "catalog:", "yargs": "18.0.0", - "open": "11.0.0", "zod": "catalog:" }, "devDependencies": { - "@internals/tools": "workspace:*", "@internals/eslint": "workspace:*", + "@internals/tools": "workspace:*", "@internals/vite": "workspace:*", "@vitest/coverage-istanbul": "catalog:", "@types/node": "catalog:", @@ -117,6 +118,13 @@ "command": "NODE_ENV=production vite build", "files": [ "../internals/tools/dist/**/*.js", + "../core/dist/bundles/index.js", + "../code/dist/bundles/index.js", + "../themes/dist/index.css", + "../themes/dist/dark.css", + "../themes/dist/high-contrast.css", + "../styles/dist/typography.css", + "../styles/dist/layout.css", "src/**", "!src/**/*.test.ts", "package.json", @@ -135,6 +143,22 @@ { "script": "../internals/tools:build", "cascade": false + }, + { + "script": "../core:build", + "cascade": false + }, + { + "script": "../code:build", + "cascade": false + }, + { + "script": "../themes:build", + "cascade": false + }, + { + "script": "../styles:build", + "cascade": false } ] }, diff --git a/projects/cli/src/index.test.ts b/projects/cli/src/index.test.ts index 85efea96a..90f84335a 100644 --- a/projects/cli/src/index.test.ts +++ b/projects/cli/src/index.test.ts @@ -99,7 +99,8 @@ describe('index', () => { input: '' }); expect(result.status).toBe(1); - expect(result.stderr + result.stdout).toContain('Invalid values'); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Invalid values'); }); }); @@ -116,4 +117,38 @@ describe('index', () => { expect(combined).toContain('nve-bar'); }); }); + + describe('tool errors', () => { + it('should exit with error when exact api lookup has no matches', () => { + const result = spawnSync('node', ['dist/index.js', 'api.get', 'nve-badges'], { + timeout: 10000, + encoding: 'utf-8', + input: '' + }); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('No components or APIs found matching'); + expect(result.stderr).toContain('nve-badges'); + }); + + it('should print structured error results when they are available', () => { + const result = spawnSync( + 'node', + ['dist/index.js', 'playground.create', 'hello'], + { + timeout: 10000, + encoding: 'utf-8', + input: '', + env: { ...process.env, CI: 'true', ELEMENTS_PLAYGROUND_BASE_URL: 'https://playground.example' } + } + ); + const lintMessages = JSON.parse(result.stderr) as { message: string }[]; + + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(Array.isArray(lintMessages)).toBe(true); + expect(lintMessages[0]?.message).toContain('Unexpected use of restricted attribute "nve-layout"'); + }); + }); }); diff --git a/projects/cli/src/index.ts b/projects/cli/src/index.ts index 8a7268daa..84bdfdca4 100755 --- a/projects/cli/src/index.ts +++ b/projects/cli/src/index.ts @@ -32,7 +32,7 @@ const yargsInstance = yargs(hideBin(process.argv)) } if (message !== null) { - console.log(colors.error(message)); + console.error(colors.error(message)); } process.exit(1); }); @@ -45,6 +45,11 @@ yargsInstance.middleware(argv => { } }); +async function exitWithToolError(result: unknown, message: string | undefined): Promise { + console.error(result === undefined ? colors.error(message ?? 'unknown error') : await renderResult(result)); + process.exit(1); +} + yargsInstance.command( '$0', 'About and help', @@ -57,8 +62,7 @@ yargsInstance.command( await renderResult(result); process.exit(0); } else { - console.log(colors.error(message ?? 'unknown error')); - process.exit(1); + await exitWithToolError(result, message); } } else { const greeting = colors.complete(`\x1b[?7l\n${JSON.parse(banner)}\n\n`); @@ -102,7 +106,6 @@ tools optionalArgs.forEach(key => builder.option(key, argOptions(properties[key]!))); }, // main handler for the command - // eslint-disable-next-line max-statements async args => { const start = performance.now(); const { result, status, message } = await runAsyncTool(args, tool); @@ -121,8 +124,7 @@ tools await notifyIfUpdateAvailable(BUILD_SHA); process.exit(0); } else { - console.log(colors.error(message ?? 'unknown error')); - process.exit(1); + await exitWithToolError(result, message); } }, // middleware to get interactive arguments when missing diff --git a/projects/cli/src/mcp/mcp.test.ts b/projects/cli/src/mcp/mcp.test.ts index 57335e334..ad9f6cb42 100644 --- a/projects/cli/src/mcp/mcp.test.ts +++ b/projects/cli/src/mcp/mcp.test.ts @@ -3,70 +3,93 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -const { mockRegisterTool, mockRegisterPrompt, mockConnect, mcpTool, cliTool, allTool, mockPrompt, mockZodSchema } = - vi.hoisted(() => { - const zodSchema = { shape: {}, optional: () => zodSchema }; - - const createMockTool = (overrides: Record) => { - const fn = vi.fn().mockResolvedValue({ status: 'complete', result: 'test' }); - Object.assign(fn, { - metadata: { - support: 3, - summary: 'Test', - description: 'Test description', - title: 'Test', - toolName: 'test_tool', - command: 'test.tool', - inputSchema: { type: 'object', properties: { name: { type: 'string' } } }, - ...overrides - } - }); - return fn; - }; +const { + mockRegisterTool, + mockRegisterPrompt, + mockRegisterResource, + mockRegisterCapabilities, + mockConnect, + mcpTool, + cliTool, + allTool, + uiTool, + mockPrompt, + mockZodSchema +} = vi.hoisted(() => { + const zodSchema = { shape: {}, optional: () => zodSchema }; - return { - mockRegisterTool: vi.fn(), - mockRegisterPrompt: vi.fn(), - mockConnect: vi.fn().mockResolvedValue(undefined), - mockZodSchema: zodSchema, - mcpTool: createMockTool({ - support: 1, - toolName: 'mcp_tool', - command: 'mcp.tool', - title: 'MCP Tool', - summary: 'MCP only', - inputSchema: undefined - }), - cliTool: createMockTool({ - support: 2, - toolName: 'cli_tool', - command: 'cli_tool', - title: 'CLI Tool', - summary: 'CLI only' - }), - allTool: createMockTool({ + const createMockTool = (overrides: Record) => { + const fn = vi.fn().mockResolvedValue({ status: 'complete', result: 'test' }); + Object.assign(fn, { + metadata: { support: 3, - toolName: 'all_tool', - command: 'all.tool', - title: 'All Tool', - summary: 'All support', - outputSchema: { type: 'string' } - }), - mockPrompt: { - name: 'test-prompt', - title: 'Test Prompt', - description: 'A test prompt', - argsSchema: { type: 'object', properties: {} }, - handler: vi.fn().mockResolvedValue({ messages: [] }) + summary: 'Test', + description: 'Test description', + title: 'Test', + toolName: 'test_tool', + command: 'test.tool', + inputSchema: { type: 'object', properties: { name: { type: 'string' } } }, + ...overrides } - }; - }); + }); + return fn; + }; + + return { + mockRegisterTool: vi.fn(), + mockRegisterPrompt: vi.fn(), + mockRegisterResource: vi.fn(), + mockRegisterCapabilities: vi.fn(), + mockConnect: vi.fn().mockResolvedValue(undefined), + mockZodSchema: zodSchema, + mcpTool: createMockTool({ + support: 1, + toolName: 'mcp_tool', + command: 'mcp.tool', + title: 'MCP Tool', + summary: 'MCP only', + inputSchema: undefined + }), + cliTool: createMockTool({ + support: 2, + toolName: 'cli_tool', + command: 'cli_tool', + title: 'CLI Tool', + summary: 'CLI only' + }), + allTool: createMockTool({ + support: 3, + toolName: 'all_tool', + command: 'all.tool', + title: 'All Tool', + summary: 'All support', + outputSchema: { type: 'string' } + }), + uiTool: createMockTool({ + support: 1, + toolName: 'mcp_ui_tool', + command: 'mcp.ui.tool', + title: 'MCP UI Tool', + summary: 'MCP UI-enabled tool', + app: { resourceUri: 'ui://elements/example-preview' } + }), + mockPrompt: { + name: 'test-prompt', + title: 'Test Prompt', + description: 'A test prompt', + argsSchema: { type: 'object', properties: {} }, + handler: vi.fn().mockResolvedValue({ messages: [] }) + } + }; +}); vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ McpServer: vi.fn(function () { return { registerTool: mockRegisterTool, registerPrompt: mockRegisterPrompt, + registerResource: mockRegisterResource, + server: { registerCapabilities: mockRegisterCapabilities }, connect: mockConnect }; }) @@ -77,7 +100,7 @@ vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ })); vi.mock('@internals/tools', () => ({ - tools: [mcpTool, cliTool, allTool], + tools: [mcpTool, cliTool, allTool, uiTool], prompts: [mockPrompt], jsonSchemaToZod: vi.fn(() => mockZodSchema), ToolSupport: { None: 0, MCP: 1, CLI: 2, All: 3 } @@ -103,9 +126,10 @@ describe('MCP server', () => { it('should only register tools with MCP support', async () => { const { startMcpServer } = await import('./mcp.js'); await startMcpServer(); - expect(mockRegisterTool).toHaveBeenCalledTimes(2); + expect(mockRegisterTool).toHaveBeenCalledTimes(3); expect(mockRegisterTool).toHaveBeenCalledWith('mcp_tool', expect.any(Object), expect.any(Function)); expect(mockRegisterTool).toHaveBeenCalledWith('all_tool', expect.any(Object), expect.any(Function)); + expect(mockRegisterTool).toHaveBeenCalledWith('mcp_ui_tool', expect.any(Object), expect.any(Function)); }); it('should not register CLI-only tools', async () => { @@ -195,6 +219,147 @@ describe('MCP server', () => { expect(result.content[0].text).toBe(JSON.stringify(objResult)); }); + it('should advertise the io.modelcontextprotocol/ui extension capability', async () => { + const { startMcpServer } = await import('./mcp.js'); + await startMcpServer(); + expect(mockRegisterCapabilities).toHaveBeenCalledWith({ + extensions: { 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] } } + }); + }); + + it('should register the MCP UI resources', async () => { + const { startMcpServer } = await import('./mcp.js'); + await startMcpServer(); + [ + { + name: 'nve-mcp-examples-render', + uri: 'ui://elements/example-preview', + title: 'Elements Example Preview' + }, + { + name: 'nve-mcp-api-icons-list', + uri: 'ui://elements/icons-list', + title: 'Elements Icons List' + }, + { + name: 'nve-mcp-api-tokens-list', + uri: 'ui://elements/tokens-list', + title: 'Elements Token Explorer' + } + ].forEach(({ name, uri, title }) => { + const resourceCall = mockRegisterResource.mock.calls.find(call => call[0] === name); + expect(resourceCall).toBeDefined(); + const [, registeredUri, config, handler] = resourceCall!; + expect(registeredUri).toBe(uri); + expect(config).toMatchObject({ mimeType: 'text/html;profile=mcp-app' }); + const result = handler(); + expect(result.contents[0]).toMatchObject({ + uri, + mimeType: 'text/html;profile=mcp-app' + }); + expect(result.contents[0].text).toContain(''); + expect(result.contents[0].text).toContain(`${title}`); + }); + }); + + it('should restrict MCP UI messages to the expected parent origin', async () => { + const { startMcpServer } = await import('./mcp.js'); + await startMcpServer(); + const resourceCall = mockRegisterResource.mock.calls.find(call => call[0] === 'nve-mcp-examples-render'); + const [, , , handler] = resourceCall!; + const result = handler(); + const html = result.contents[0].text; + + expect(html).toContain('window.parent.postMessage(msg, expectedOrigin)'); + expect(html).toContain('#getExpectedParentOrigin()'); + expect(html).toContain('#isAllowedParentOrigin(origin)'); + expect(html).toContain("origin !== 'null'"); + expect(html).not.toContain("window.parent.postMessage(msg, '*')"); + }); + + it('should keep the example preview element decoupled from the MCP UI client', async () => { + const { startMcpServer } = await import('./mcp.js'); + await startMcpServer(); + const resourceCall = mockRegisterResource.mock.calls.find(call => call[0] === 'nve-mcp-examples-render'); + const [, , , handler] = resourceCall!; + const result = handler(); + const html = result.contents[0].text; + const elementScript = html.slice( + html.indexOf('class ElementsExamplePreview'), + html.indexOf("customElements.define('nve-mcp-examples-render'") + ); + const clientScript = html.slice(html.indexOf('const client = new Client')); + + expect(elementScript).not.toContain('callServerTool'); + expect(elementScript).not.toContain('handleToolInput'); + expect(elementScript).not.toContain('handleToolResult'); + expect(elementScript).not.toContain('structuredContent'); + expect(elementScript).not.toMatch(/this\.client\s*=/); + expect(clientScript).toContain('client.callServerTool'); + expect(clientScript).toContain("name: 'examples_get'"); + expect(clientScript).toContain('lintMessages'); + expect(clientScript).not.toContain('preview.template = pendingTemplate'); + }); + + it('should keep the icons list element decoupled from the MCP UI client', async () => { + const { startMcpServer } = await import('./mcp.js'); + await startMcpServer(); + const resourceCall = mockRegisterResource.mock.calls.find(call => call[0] === 'nve-mcp-api-icons-list'); + const [, , , handler] = resourceCall!; + const result = handler(); + const html = result.contents[0].text; + const elementScript = html.slice( + html.indexOf('class ElementsIconsList'), + html.indexOf("customElements.define('nve-mcp-api-icons-list'") + ); + const clientScript = html.slice(html.indexOf('const client = new Client')); + + expect(elementScript).toContain("new CustomEvent('icons-request'"); + expect(elementScript).not.toContain('callServerTool'); + expect(elementScript).not.toContain('handleToolResult'); + expect(elementScript).not.toContain('structuredContent'); + expect(elementScript).not.toMatch(/this\.client\s*=/); + expect(clientScript).toContain('client.callServerTool'); + expect(clientScript).toContain("iconList.addEventListener('icons-request'"); + }); + + it('should keep the tokens list element decoupled from the MCP UI client', async () => { + const { startMcpServer } = await import('./mcp.js'); + await startMcpServer(); + const resourceCall = mockRegisterResource.mock.calls.find(call => call[0] === 'nve-mcp-api-tokens-list'); + const [, , , handler] = resourceCall!; + const result = handler(); + const html = result.contents[0].text; + const elementScript = html.slice( + html.indexOf('class ElementsTokensList'), + html.indexOf("customElements.define('nve-mcp-api-tokens-list'") + ); + const clientScript = html.slice(html.indexOf('const client = new Client')); + + expect(elementScript).toContain("new CustomEvent('tokens-request'"); + expect(elementScript).not.toContain('callServerTool'); + expect(elementScript).not.toContain('handleToolInput'); + expect(elementScript).not.toContain('handleToolResult'); + expect(elementScript).not.toContain('structuredContent'); + expect(elementScript).not.toMatch(/this\.client\s*=/); + expect(clientScript).toContain('client.callServerTool'); + expect(clientScript).toContain("tokenList.addEventListener('tokens-request'"); + }); + + it('should attach _meta.ui to tools that declare MCP UI metadata', async () => { + const { startMcpServer } = await import('./mcp.js'); + await startMcpServer(); + const uiToolCall = mockRegisterTool.mock.calls.find(call => call[0] === 'mcp_ui_tool'); + expect(uiToolCall![1]._meta).toEqual({ ui: { resourceUri: 'ui://elements/example-preview' } }); + }); + + it('should leave _meta undefined for tools without MCP UI metadata', async () => { + const { startMcpServer } = await import('./mcp.js'); + await startMcpServer(); + const mcpToolCall = mockRegisterTool.mock.calls.find(call => call[0] === 'mcp_tool'); + expect(mcpToolCall![1]._meta).toBeUndefined(); + }); + it('should exit on connection error', async () => { mockConnect.mockRejectedValueOnce(new Error('Connection failed')); const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); diff --git a/projects/cli/src/mcp/mcp.ts b/projects/cli/src/mcp/mcp.ts index f61833d34..0b21b2b9a 100644 --- a/projects/cli/src/mcp/mcp.ts +++ b/projects/cli/src/mcp/mcp.ts @@ -7,20 +7,49 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { tools, prompts, jsonSchemaToZod, ToolSupport } from '@internals/tools'; import z, { type ZodObject } from 'zod'; import { type ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import { MCP_UI_MIME_TYPE, uiResources } from './ui/index.js'; const VERSION = '0.0.0'; -// eslint-disable-next-line max-lines-per-function -export async function startMcpServer() { - process.env.ELEMENTS_ENV = 'mcp'; +function registerCapabilities(server: McpServer): void { + server.server.registerCapabilities({ + extensions: { 'io.modelcontextprotocol/ui': { mimeTypes: [MCP_UI_MIME_TYPE] } } + }); +} - const server = new McpServer({ - name: 'nve-mcp', - version: VERSION, - description: - 'NVIDIA Elements UI Design System (nve-*), custom element schemas, APIs and examples. Use the "elements" skill for more guidance if available.' +function registerResources(server: McpServer): void { + uiResources.forEach(resource => { + server.registerResource( + resource.name, + resource.resourceUri, + { mimeType: resource.mimeType, description: resource.description }, + () => ({ + contents: [{ uri: resource.resourceUri, mimeType: resource.mimeType, text: resource.getHtml() }] + }) + ); }); +} +function attachProgress( + params: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extra: any +): void { + const progressToken = extra?._meta?.progressToken; + if (progressToken === undefined) return; + let progressCount = 0; + params.onProgress = (message: string) => { + progressCount++; + extra + .sendNotification({ + method: 'notifications/progress', + params: { progressToken, progress: progressCount, message } + }) + .catch(() => undefined); + }; +} + +function registerTools(server: McpServer): void { tools .filter(tool => tool.metadata.support & ToolSupport.MCP) .forEach(tool => { @@ -29,40 +58,27 @@ export async function startMcpServer() { ? (jsonSchemaToZod(tool.metadata.inputSchema) as ZodObject>).shape : {}; const resultSchema = tool.metadata.outputSchema ? jsonSchemaToZod(tool.metadata.outputSchema) : z.any(); - const outputSchema = { - status: z.enum(['complete', 'error']).optional(), - message: z.string().optional(), - result: resultSchema.optional() - }; - const config = { title, inputSchema, - outputSchema, + outputSchema: { + status: z.enum(['complete', 'error']).optional(), + message: z.string().optional(), + result: resultSchema.optional() + }, description: description ? description : summary, annotations: { title, - readOnlyHint: true, // If true, the tool does not change its environment - idempotentHint: true, // If true, repeated calls with same args have no extra effect - destructiveHint: false, // If true, the tool may perform destructive/irreversible updates - openWorldHint: false, // If true, tool interacts with external entities + readOnlyHint: true, + idempotentHint: true, + destructiveHint: false, + openWorldHint: false, ...tool.metadata.annotations - } as ToolAnnotations + } as ToolAnnotations, + _meta: !tool.metadata.app ? undefined : { ui: { resourceUri: tool.metadata.app.resourceUri } } }; server.registerTool(toolName, config, async (params, extra) => { - const progressToken = extra?._meta?.progressToken; - let progressCount = 0; - if (progressToken !== undefined) { - (params as Record).onProgress = (message: string) => { - progressCount++; - extra - .sendNotification({ - method: 'notifications/progress', - params: { progressToken, progress: progressCount, message } - }) - .catch(() => {}); - }; - } + attachProgress(params as Record, extra); const structuredContent = (await tool(params)) as unknown as { [x: string]: unknown }; // https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1624 const text = @@ -72,12 +88,30 @@ export async function startMcpServer() { return { structuredContent, content: [{ type: 'text', text }] }; }); }); +} +function registerPrompts(server: McpServer): void { prompts.forEach(prompt => { const argsSchema = (jsonSchemaToZod(prompt.argsSchema ?? {}) as ZodObject>).shape; const config = { title: prompt.title, description: prompt.description, argsSchema }; server.registerPrompt(prompt.name, config, async params => prompt.handler(params)); }); +} + +export async function startMcpServer() { + process.env.ELEMENTS_ENV = 'mcp'; + + const server = new McpServer({ + name: 'nve-mcp', + version: VERSION, + description: + 'NVIDIA Elements UI Design System (nve-*), custom element schemas, APIs and examples. Use the "elements" skill for more guidance if available.' + }); + + registerCapabilities(server); + registerResources(server); + registerTools(server); + registerPrompts(server); try { await server.connect(new StdioServerTransport()); diff --git a/projects/cli/src/mcp/ui/api-icons-list.js b/projects/cli/src/mcp/ui/api-icons-list.js new file mode 100644 index 000000000..c61264158 --- /dev/null +++ b/projects/cli/src/mcp/ui/api-icons-list.js @@ -0,0 +1,311 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const iconsListStyle = /* css */ ` +:host { + display: block; +} + +.icons-shell { + display: flex; + flex-direction: column; + gap: var(--nve-ref-space-sm); +} + +.icons-count { + color: var(--nve-sys-text-muted-color); + font-size: var(--nve-ref-font-size-100); + font-weight: var(--nve-ref-font-weight-bold); + line-height: var(--nve-ref-line-height-sm); +} + +.icons-grid, +.icons-empty, +.icons-error { + display: flex; + flex-wrap: wrap; + gap: var(--nve-ref-space-xs); + max-height: 480px; + overflow-y: auto; + scrollbar-color: var(--nve-sys-scrollbar-thumb-color) var(--nve-sys-scrollbar-track-color); + scrollbar-width: var(--nve-sys-scrollbar-width); +} + +.icons-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: var(--nve-ref-space-xs); + + nve-button { + --height: 80px; + --border-radius: var(--nve-ref-border-radius-sm); + --font-size: var(--nve-ref-font-size-50); + } + + .icon-button-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--nve-ref-space-xs); + } +} +`; + +const iconsListStyleSheet = new CSSStyleSheet(); +iconsListStyleSheet.replaceSync(iconsListStyle); + +class ElementsIconsList extends HTMLElement { + constructor() { + super(); + this.renderRoot = this.attachShadow({ mode: 'open' }); + this.renderRoot.adoptedStyleSheets = [iconsListStyleSheet]; + this.state = { icons: [], errorMessage: '', loaded: false, loading: false, query: '' }; + this.grid = null; + this.count = null; + this.copiedToast = null; + } + + get icons() { + return this.state.icons; + } + + set icons(value) { + const icons = Array.isArray(value) ? value.filter(name => typeof name === 'string' && name.length > 0) : []; + this.#setIcons(icons); + } + + get errorMessage() { + return this.state.errorMessage; + } + + set errorMessage(value) { + this.state.errorMessage = typeof value === 'string' ? value : ''; + this.state.loaded = false; + this.state.loading = false; + this.#renderCurrentState(); + } + + connectedCallback() { + this.#renderShell(); + this.#requestIcons(); + } + + #iconSource(name) { + return ''; + } + + #renderShell() { + const root = document.createElement('main'); + root.className = 'icons-shell'; + this.count = document.createElement('span'); + this.count.className = 'icons-count'; + + const searchControl = document.createElement('nve-search'); + searchControl.setAttribute('rounded', ''); + const search = document.createElement('input'); + search.type = 'search'; + search.placeholder = 'Search icons'; + search.setAttribute('aria-label', 'Search icons'); + search.autocomplete = 'off'; + search.addEventListener('input', () => { + this.state.query = search.value.trim().toLowerCase(); + if (this.state.loaded) { + this.#renderIcons(); + } + }); + searchControl.append(search); + + this.grid = document.createElement('section'); + + this.copiedToast = document.createElement('nve-toast'); + this.copiedToast.setAttribute('hidden', ''); + this.copiedToast.setAttribute('status', 'success'); + this.copiedToast.setAttribute('position', 'top'); + this.copiedToast.setAttribute('close-timeout', '1500'); + this.copiedToast.textContent = 'Copied'; + + root.append(this.count, searchControl, this.grid, this.copiedToast); + this.renderRoot.replaceChildren(root); + this.#renderCurrentState(); + } + + #renderCurrentState() { + if (!this.count || !this.grid) return; + if (this.state.errorMessage) { + this.#renderError(this.state.errorMessage); + return; + } + if (this.state.loaded) { + this.#renderIcons(); + return; + } + this.#renderLoading(); + } + + #renderLoading() { + this.count.textContent = 'Loading'; + this.grid.className = 'icons-empty'; + this.grid.textContent = 'Loading icons'; + } + + #renderError(message) { + this.count.textContent = 'Unavailable'; + this.grid.className = 'icons-error'; + this.grid.textContent = message; + } + + #renderIcons() { + const filtered = this.state.query + ? this.state.icons.filter(name => name.includes(this.state.query)) + : this.state.icons; + this.count.textContent = filtered.length + ' of ' + this.state.icons.length; + this.grid.className = filtered.length ? 'icons-grid' : 'icons-empty'; + this.grid.replaceChildren(); + if (!filtered.length) { + this.grid.textContent = 'No icons found'; + return; + } + filtered.forEach(name => this.grid.append(this.#createIconCard(name))); + } + + #createIconCard(name) { + const copyButton = document.createElement('nve-button'); + copyButton.setAttribute('aria-label', 'Copy ' + name + ' source'); + copyButton.title = 'Copy source'; + + const copyButtonContent = document.createElement('div'); + copyButtonContent.className = 'icon-button-content'; + + const icon = document.createElement('nve-icon'); + icon.setAttribute('name', name); + icon.setAttribute('aria-hidden', 'true'); + icon.size = 'lg'; + + const iconName = document.createElement('span'); + iconName.className = 'icon-name'; + iconName.textContent = name; + + copyButtonContent.append(icon, iconName); + copyButton.append(copyButtonContent); + copyButton.addEventListener('click', () => { + void this.#copySource(this.#iconSource(name), copyButton); + }); + + return copyButton; + } + + async #copySource(source, button) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(source); + } else { + this.#copyWithTextArea(source); + } + } catch (_err) { + this.#copyWithTextArea(source); + } + button.dataset.copied = 'true'; + this.#showCopiedToast(button); + window.setTimeout(() => { + button.dataset.copied = 'false'; + }, 1200); + } + + #showCopiedToast(button) { + if (!this.copiedToast) return; + if (this.copiedToast.matches(':popover-open')) { + this.copiedToast.hidePopover(); + } + this.copiedToast.showPopover({ source: button }); + } + + #copyWithTextArea(source) { + const textarea = document.createElement('textarea'); + textarea.value = source; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.append(textarea); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); + } + + #setIcons(icons) { + this.state.icons = icons; + this.state.errorMessage = ''; + this.state.loaded = true; + this.state.loading = false; + this.#renderCurrentState(); + } + + #requestIcons() { + if (this.state.loaded || this.state.loading) return; + this.state.loading = true; + this.dispatchEvent(new CustomEvent('icons-request', { bubbles: true, composed: true })); + } +} + +customElements.define('nve-mcp-api-icons-list', ElementsIconsList); + +const client = new Client({ name: 'Elements Icons List', version: '1.0.0' }); + +const iconList = document.createElement('nve-mcp-api-icons-list'); + +function getStructuredResult(payload) { + if (!payload) return undefined; + if (payload.structuredContent && payload.structuredContent.result !== undefined) + return payload.structuredContent.result; + if (payload.result && payload.result.structuredContent) return payload.result.structuredContent.result; + if (payload.result !== undefined) return payload.result; + return undefined; +} + +function getIconNames(payload) { + const result = getStructuredResult(payload); + return Array.isArray(result) ? result.filter(name => typeof name === 'string' && name.length > 0) : []; +} + +let iconsLoaded = false; +let iconsLoading = false; + +function setIconsFromPayload(payload) { + const icons = getIconNames(payload); + if (!icons.length) return false; + iconsLoaded = true; + iconsLoading = false; + iconList.icons = icons; + return true; +} + +async function loadIcons() { + if (iconsLoaded || iconsLoading) return; + iconsLoading = true; + try { + const res = await client.callServerTool({ + name: 'api_icons_list', + arguments: { format: 'json' } + }); + if (!setIconsFromPayload(res)) { + iconsLoading = false; + iconList.errorMessage = 'Failed to load icons: no icons returned'; + } + } catch (err) { + iconsLoading = false; + iconList.errorMessage = 'Failed to load icons: ' + (err && err.message ? err.message : 'unknown error'); + } +} + +iconList.addEventListener('icons-request', () => { + void loadIcons(); +}); + +client.ontoolresult = params => { + if (!setIconsFromPayload(params)) { + void loadIcons(); + } +}; + +client.connect(); +document.body.replaceChildren(iconList); diff --git a/projects/cli/src/mcp/ui/api-tokens-list.js b/projects/cli/src/mcp/ui/api-tokens-list.js new file mode 100644 index 000000000..ea55bfb56 --- /dev/null +++ b/projects/cli/src/mcp/ui/api-tokens-list.js @@ -0,0 +1,712 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const tokensListStyle = /* css */ ` + :host { + display: block; + } + + .tokens-shell { + display: grid; + gap: var(--nve-ref-space-sm); + } + + .tokens-toolbar { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--nve-ref-space-sm); + } + + .tokens-count, + .token-section-count { + color: var(--nve-sys-text-muted-color); + font-size: var(--nve-ref-font-size-100); + font-weight: var(--nve-ref-font-weight-bold); + line-height: var(--nve-ref-line-height-sm); + } + + .tokens-search { + flex: 1 1 240px; + min-width: 200px; + } + + .theme-toggle { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + gap: var(--nve-ref-space-xs); + } + + .tokens-groups { + display: grid; + gap: var(--nve-ref-space-lg); + margin-top: var(--nve-ref-space-md); + max-height: 480px; + overflow-y: auto; + padding-right: var(--nve-ref-size-100); + scrollbar-color: var(--nve-sys-scrollbar-thumb-color) var(--nve-sys-scrollbar-track-color); + scrollbar-width: var(--nve-sys-scrollbar-width); + } + + .token-title-row { + align-items: center; + display: flex; + gap: var(--nve-ref-space-xs); + } + + .token-heading { + color: var(--nve-sys-text-emphasis-color); + font-size: var(--nve-ref-font-size-400); + font-weight: var(--nve-ref-font-weight-bold); + line-height: var(--nve-ref-line-height-sm); + margin: 0; + } + + .token-table { + --scroll-height: none; + width: 100%; + } + + .token-preview-cell { + --justify-content: center; + } + + .token-description { + color: var(--nve-sys-text-muted-color); + display: block; + font-size: var(--nve-ref-font-size-100); + line-height: var(--nve-ref-line-height-sm); + min-width: 0; + overflow-wrap: anywhere; + } + + .token-preview { + border: var(--nve-ref-border-width-sm) solid var(--nve-ref-border-color-muted); + border-radius: var(--nve-ref-border-radius-sm); + display: block; + min-height: var(--nve-ref-size-800); + overflow: hidden; + width: min(100%, 96px); + } + + .color-preview { + background-image: + linear-gradient(45deg, color-mix(in oklab, CanvasText 12%, transparent) 25%, transparent 25%), + linear-gradient(-45deg, color-mix(in oklab, CanvasText 12%, transparent) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, color-mix(in oklab, CanvasText 12%, transparent) 75%), + linear-gradient(-45deg, transparent 75%, color-mix(in oklab, CanvasText 12%, transparent) 75%); + background-position: 0 0, 0 6px, 6px -6px, -6px 0; + background-size: 12px 12px; + } + + .color-swatch { + display: block; + min-height: var(--nve-ref-size-800); + } + + .ruler-preview { + align-items: center; + background: var(--nve-sys-layer-container-accent-background); + display: flex; + padding: var(--nve-ref-size-100); + } + + .ruler-fill { + background: var(--nve-sys-support-color); + border-radius: var(--nve-ref-border-radius-xs); + display: block; + height: var(--nve-ref-size-200); + } + + .type-preview { + align-items: center; + background: var(--nve-sys-layer-container-accent-background); + color: var(--nve-sys-text-color); + display: flex; + min-height: var(--nve-ref-size-1000); + padding: var(--nve-ref-size-100) var(--nve-ref-size-200); + } + + .radius-preview { + align-items: center; + background: var(--nve-sys-layer-container-accent-background); + display: flex; + justify-content: center; + min-height: var(--nve-ref-size-1000); + padding: var(--nve-ref-size-100); + } + + .radius-shape { + background: var(--nve-sys-layer-container-accent-background); + border: var(--nve-ref-border-width-md) solid var(--nve-ref-border-color-emphasis); + display: block; + height: var(--nve-ref-size-800); + width: 80px; + } + + .shadow-preview { + align-items: center; + background: var(--nve-sys-layer-canvas-accent-background); + display: flex; + justify-content: center; + min-height: var(--nve-ref-size-1000); + padding: var(--nve-ref-size-100); + } + + .shadow-shape { + background: var(--nve-sys-layer-container-background); + border: var(--nve-ref-border-width-sm) solid var(--nve-ref-border-color-muted); + border-radius: var(--nve-ref-border-radius-sm); + display: block; + height: var(--nve-ref-size-800); + width: 80px; + } + + .token-empty, + .token-error { + color: var(--nve-sys-text-muted-color); + padding-block: var(--nve-ref-size-400); + } +`; + +const tokensListStyleSheet = new CSSStyleSheet(); +tokensListStyleSheet.replaceSync(tokensListStyle); + +const colorValuePrefixes = ['#', 'rgb', 'hsl', 'oklch']; +const colorValueNames = new Set([ + 'canvas', + 'canvastext', + 'buttonface', + 'buttontext', + 'fieldtext', + 'graytext', + 'linktext' +]); +const tokenCategoryMatchers = [ + { category: 'shadow', nameKeywords: ['shadow'] }, + { category: 'radius', nameKeywords: ['radius', 'border-width'] }, + { category: 'typography', nameKeywords: ['font'] }, + { category: 'spacing', nameKeywords: ['space', 'size', 'height', 'width', 'gap', 'padding', 'offset', 'margin'] }, + { + category: 'color', + nameKeywords: ['color', 'background', 'accent', 'status', 'support', 'visualization'], + valueChecks: [ + value => colorValuePrefixes.some(prefix => value.startsWith(prefix)), + value => colorValueNames.has(value) + ] + } +]; + +class ElementsTokensList extends HTMLElement { + constructor() { + super(); + this.renderRoot = this.attachShadow({ mode: 'open' }); + this.renderRoot.adoptedStyleSheets = [tokensListStyleSheet]; + this.state = { tokens: [], errorMessage: '', loaded: false, loading: false, query: '', theme: '' }; + this.count = null; + this.groups = null; + this.searchInput = null; + this.copiedToast = null; + this.themeButtons = new Map(); + } + + connectedCallback() { + this.#renderShell(); + this.#setTheme(this.#getPreferredTheme()); + this.#requestTokens(); + } + + get tokens() { + return this.state.tokens; + } + + set tokens(value) { + const tokens = Array.isArray(value) + ? value + .filter(token => token && typeof token.name === 'string' && typeof token.value === 'string') + .map(token => ({ + name: token.name.startsWith('--') ? token.name : '--' + token.name, + value: token.value, + description: typeof token.description === 'string' ? token.description : '' + })) + : []; + this.#setTokens(tokens); + } + + get errorMessage() { + return this.state.errorMessage; + } + + set errorMessage(value) { + this.state.errorMessage = typeof value === 'string' ? value : ''; + this.state.loaded = false; + this.state.loading = false; + this.#renderCurrentState(); + } + + get query() { + return this.state.query; + } + + set query(value) { + this.#setQuery(value); + } + + #tokenReference(name) { + return 'var(' + name + ')'; + } + + #getPreferredTheme() { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + #setQuery(query, render = true) { + const value = typeof query === 'string' ? query.trim() : ''; + this.state.query = value.toLowerCase(); + if (this.searchInput && this.searchInput.value !== value) { + this.searchInput.value = value; + } + if (render && this.state.loaded) { + this.#renderTokens(); + } + } + + #classifyToken(token) { + const name = token.name.toLowerCase(); + const value = token.value.toLowerCase(); + const match = tokenCategoryMatchers.find(({ nameKeywords, valueChecks = [] }) => { + return nameKeywords.some(keyword => name.includes(keyword)) || valueChecks.some(check => check(value)); + }); + + return match?.category ?? 'other'; + } + + #categoryLabel(category) { + switch (category) { + case 'color': + return 'Colors'; + case 'spacing': + return 'Spacing and size'; + case 'typography': + return 'Typography'; + case 'radius': + return 'Radii and borders'; + case 'shadow': + return 'Shadows'; + case 'other': + return 'Other'; + default: + return 'Tokens'; + } + } + + #renderShell() { + const root = document.createElement('main'); + root.className = 'tokens-shell'; + + const toolbar = document.createElement('section'); + toolbar.className = 'tokens-toolbar'; + + this.count = document.createElement('span'); + this.count.className = 'tokens-count'; + + const searchControl = document.createElement('nve-search'); + searchControl.className = 'tokens-search'; + searchControl.setAttribute('rounded', ''); + const search = document.createElement('input'); + search.type = 'search'; + search.placeholder = 'Search tokens'; + search.setAttribute('aria-label', 'Search tokens'); + search.autocomplete = 'off'; + this.searchInput = search; + search.addEventListener('input', () => { + this.#setQuery(search.value); + }); + searchControl.append(search); + + const themeToggle = document.createElement('section'); + themeToggle.className = 'theme-toggle'; + themeToggle.setAttribute('role', 'group'); + themeToggle.setAttribute('aria-label', 'Theme'); + [ + ['light', 'Light'], + ['dark', 'Dark'], + ['high-contrast', 'High contrast'] + ].forEach(([theme, label]) => { + const button = document.createElement('nve-button'); + button.setAttribute('type', 'button'); + button.setAttribute('size', 'sm'); + button.textContent = label; + button.addEventListener('click', () => this.#setTheme(theme)); + this.themeButtons.set(theme, button); + themeToggle.append(button); + }); + + toolbar.append(this.count, searchControl, themeToggle); + + this.groups = document.createElement('section'); + this.groups.className = 'tokens-groups'; + + this.copiedToast = document.createElement('nve-toast'); + this.copiedToast.setAttribute('hidden', ''); + this.copiedToast.setAttribute('status', 'success'); + this.copiedToast.setAttribute('position', 'top'); + this.copiedToast.setAttribute('close-timeout', '1500'); + this.copiedToast.textContent = 'Copied'; + + root.append(toolbar, this.groups, this.copiedToast); + this.renderRoot.replaceChildren(root); + this.#renderCurrentState(); + } + + #renderCurrentState() { + if (!this.count || !this.groups) return; + if (this.state.errorMessage) { + this.#renderError(this.state.errorMessage); + return; + } + if (this.state.loaded) { + this.#renderTokens(); + return; + } + this.#renderLoading(); + } + + #setTheme(theme) { + this.state.theme = theme; + document.documentElement.setAttribute('nve-theme', theme); + this.themeButtons.forEach((button, key) => { + button.toggleAttribute('pressed', key === theme); + }); + } + + #renderLoading() { + this.count.textContent = 'Loading'; + this.groups.className = 'tokens-groups token-empty'; + this.groups.textContent = 'Loading tokens'; + } + + #renderError(message) { + this.count.textContent = 'Unavailable'; + this.groups.className = 'tokens-groups token-error'; + this.groups.textContent = message; + } + + #renderTokens() { + const filtered = this.state.query + ? this.state.tokens.filter(token => { + const query = this.state.query; + return ( + token.name.toLowerCase().includes(query) || + token.value.toLowerCase().includes(query) || + token.description.toLowerCase().includes(query) + ); + }) + : this.state.tokens; + + this.count.textContent = filtered.length + ' of ' + this.state.tokens.length; + this.groups.className = 'tokens-groups'; + this.groups.replaceChildren(); + + if (!filtered.length) { + this.groups.classList.add('token-empty'); + this.groups.textContent = 'No tokens found'; + return; + } + + const categories = ['color', 'spacing', 'typography', 'radius', 'shadow', 'other']; + categories.forEach(category => { + const tokens = filtered.filter(token => this.#classifyToken(token) === category); + if (!tokens.length) return; + this.groups.append(this.#createTokenSection(category, tokens)); + }); + } + + #createTokenSection(category, tokens) { + const section = document.createElement('section'); + section.className = 'token-section token-section-' + category; + + const titleRow = document.createElement('div'); + titleRow.className = 'token-title-row'; + const heading = document.createElement('h2'); + heading.className = 'token-heading'; + heading.textContent = this.#categoryLabel(category); + const count = document.createElement('span'); + count.className = 'token-section-count'; + count.textContent = String(tokens.length); + titleRow.append(heading, count); + + const grid = document.createElement('nve-grid'); + grid.className = 'token-table token-table-' + category; + grid.setAttribute('container', 'flat'); + + const header = document.createElement('nve-grid-header'); + [ + ['Preview', '124px', 'center'], + ['Token', '280px', 'start'], + ['Guidance', '', 'start'] + ].forEach(([label, width, align]) => { + const column = document.createElement('nve-grid-column'); + column.textContent = label; + if (width) column.setAttribute('width', width); + column.setAttribute('column-align', align); + header.append(column); + }); + + grid.append(header); + tokens.forEach(token => grid.append(this.#createTokenRow(token, category))); + + section.append(titleRow, grid); + return section; + } + + #createTokenRow(token, category) { + const row = document.createElement('nve-grid-row'); + row.className = 'token-row token-row-' + category; + + const previewCell = document.createElement('nve-grid-cell'); + previewCell.className = 'token-preview-cell'; + previewCell.append(this.#createPreview(token, category)); + + const tokenCell = document.createElement('nve-grid-cell'); + const copyButton = document.createElement('nve-button'); + copyButton.className = 'token-copy-button-' + category; + copyButton.setAttribute('container', 'flat'); + copyButton.setAttribute('aria-label', 'Copy ' + this.#tokenReference(token.name)); + copyButton.title = 'Copy ' + this.#tokenReference(token.name); + copyButton.textContent = token.name; + copyButton.addEventListener('click', () => { + void this.#copySource(this.#tokenReference(token.name), copyButton); + }); + tokenCell.append(copyButton); + + const guidanceCell = document.createElement('nve-grid-cell'); + const description = document.createElement('span'); + description.className = 'token-description'; + description.textContent = token.description || token.value; + guidanceCell.append(description); + + row.append(previewCell, tokenCell, guidanceCell); + return row; + } + + #createPreview(token, category) { + if (category === 'color') return this.#createColorPreview(token); + if (category === 'spacing') return this.#createRulerPreview(token); + if (category === 'typography') return this.#createTypePreview(token); + if (category === 'radius') return this.#createRadiusPreview(token); + if (category === 'shadow') return this.#createShadowPreview(token); + return this.#createOtherPreview(token); + } + + #createColorPreview(token) { + const preview = document.createElement('span'); + preview.className = 'token-preview color-preview'; + const swatch = document.createElement('span'); + swatch.className = 'color-swatch'; + swatch.style.background = this.#tokenReference(token.name); + preview.append(swatch); + return preview; + } + + #createRulerPreview(token) { + const preview = document.createElement('span'); + preview.className = 'token-preview ruler-preview'; + const fill = document.createElement('span'); + fill.className = 'ruler-fill'; + fill.style.width = 'clamp(2px, ' + this.#tokenReference(token.name) + ', 180px)'; + preview.append(fill); + return preview; + } + + #createTypePreview(token) { + const preview = document.createElement('span'); + preview.className = 'token-preview type-preview'; + const specimen = document.createElement('span'); + specimen.textContent = 'Ag'; + specimen.style.fontFamily = 'var(--nve-ref-font-family)'; + if (token.name.includes('font-size')) { + specimen.style.fontSize = this.#tokenReference(token.name); + } else if (token.name.includes('font-weight')) { + specimen.style.fontWeight = this.#tokenReference(token.name); + specimen.style.fontSize = 'var(--nve-ref-font-size-600)'; + } else if (token.name.includes('font-family')) { + specimen.style.fontFamily = this.#tokenReference(token.name); + specimen.style.fontSize = 'var(--nve-ref-font-size-600)'; + } + preview.append(specimen); + return preview; + } + + #createRadiusPreview(token) { + const preview = document.createElement('span'); + preview.className = 'token-preview radius-preview'; + const shape = document.createElement('span'); + shape.className = 'radius-shape'; + if (token.name.includes('border-width')) { + shape.style.borderWidth = this.#tokenReference(token.name); + } else { + shape.style.borderRadius = this.#tokenReference(token.name); + } + preview.append(shape); + return preview; + } + + #createShadowPreview(token) { + const preview = document.createElement('span'); + preview.className = 'token-preview shadow-preview'; + const shape = document.createElement('span'); + shape.className = 'shadow-shape'; + shape.style.boxShadow = this.#tokenReference(token.name); + preview.append(shape); + return preview; + } + + #createOtherPreview(token) { + const preview = document.createElement('span'); + preview.className = 'token-preview type-preview token-other-preview'; + preview.textContent = token.value; + return preview; + } + + async #copySource(source, button) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(source); + } else { + this.#copyWithTextArea(source); + } + } catch (_err) { + this.#copyWithTextArea(source); + } + button.dataset.copied = 'true'; + this.#showCopiedToast(button, source); + window.setTimeout(() => { + button.dataset.copied = 'false'; + }, 1200); + } + + #showCopiedToast(button, source) { + if (!this.copiedToast) return; + this.copiedToast.textContent = 'Copied ' + source; + if (this.copiedToast.matches(':popover-open')) { + this.copiedToast.hidePopover(); + } + this.copiedToast.showPopover({ source: button }); + } + + #copyWithTextArea(source) { + const textarea = document.createElement('textarea'); + textarea.value = source; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.append(textarea); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); + } + + #setTokens(tokens) { + this.state.tokens = tokens; + this.state.errorMessage = ''; + this.state.loaded = true; + this.state.loading = false; + this.#renderCurrentState(); + } + + #requestTokens() { + if (this.state.loaded || this.state.loading) return; + this.state.loading = true; + this.dispatchEvent(new CustomEvent('tokens-request', { bubbles: true, composed: true })); + } +} + +customElements.define('nve-mcp-api-tokens-list', ElementsTokensList); + +const client = new Client({ name: 'Elements Token Explorer', version: '1.0.0' }); + +const tokenList = document.createElement('nve-mcp-api-tokens-list'); + +function getStructuredResult(payload) { + if (!payload) return undefined; + if (payload.structuredContent && payload.structuredContent.result !== undefined) + return payload.structuredContent.result; + if (payload.result && payload.result.structuredContent) return payload.result.structuredContent.result; + if (payload.result !== undefined) return payload.result; + return undefined; +} + +function getTokens(payload) { + const result = getStructuredResult(payload); + if (!Array.isArray(result)) return []; + return result + .filter(token => token && typeof token.name === 'string' && typeof token.value === 'string') + .map(token => ({ + name: token.name.startsWith('--') ? token.name : '--' + token.name, + value: token.value, + description: typeof token.description === 'string' ? token.description : '' + })); +} + +function hasTokenResult(payload) { + return Array.isArray(getStructuredResult(payload)); +} + +let tokensLoaded = false; +let tokensLoading = false; + +function setTokensFromPayload(payload) { + if (!hasTokenResult(payload)) return false; + tokensLoaded = true; + tokensLoading = false; + tokenList.tokens = getTokens(payload); + return true; +} + +async function loadTokens({ force = false, silent = false } = {}) { + if ((!force && tokensLoaded) || tokensLoading) return; + tokensLoading = true; + try { + const res = await client.callServerTool({ + name: 'api_tokens_list', + arguments: { format: 'json' } + }); + if (!setTokensFromPayload(res)) { + tokensLoading = false; + if (!silent) { + tokenList.errorMessage = 'Failed to load tokens: no tokens returned'; + } + } + } catch (err) { + tokensLoading = false; + if (!silent) { + tokenList.errorMessage = 'Failed to load tokens: ' + (err && err.message ? err.message : 'unknown error'); + } + } +} + +tokenList.addEventListener('tokens-request', () => { + void loadTokens(); +}); + +client.ontoolresult = params => { + if (setTokensFromPayload(params)) { + if (tokenList.query) { + void loadTokens({ force: true, silent: true }); + } + return; + } + void loadTokens(); +}; + +client.ontoolinput = params => { + const args = params && params.arguments; + if (args && typeof args.query === 'string') { + tokenList.query = args.query; + } +}; + +client.connect(); +document.body.replaceChildren(tokenList); diff --git a/projects/cli/src/mcp/ui/client.js b/projects/cli/src/mcp/ui/client.js new file mode 100644 index 000000000..7ccf0dcdc --- /dev/null +++ b/projects/cli/src/mcp/ui/client.js @@ -0,0 +1,115 @@ +class Client { + #nextId = 0; + #handlers = new Map(); + #lastReportedWidth = -1; + #lastReportedHeight = -1; + #expectedParentOrigin; + + constructor(info) { + this.name = info.name; + this.version = info.version; + } + + connect() { + window.addEventListener('message', e => { + const expectedOrigin = this.#getExpectedParentOrigin(); + if (!expectedOrigin || e.origin !== expectedOrigin || e.source !== window.parent) return; + const m = e.data; + if (!m || m.jsonrpc !== '2.0') return; + if (m.id !== undefined && this.#handlers.has(m.id)) { + const { resolve, reject } = this.#handlers.get(m.id); + this.#handlers.delete(m.id); + if (m.error) reject(new Error(m.error.message || 'rpc error')); + else resolve(m.result); + return; + } + if (m.method === 'ui/notifications/tool-input' && this.ontoolinput) this.ontoolinput(m.params || {}); + if (m.method === 'ui/notifications/tool-result' && this.ontoolresult) this.ontoolresult(m.params || {}); + }); + this.#send({ + jsonrpc: '2.0', + id: ++this.#nextId, + method: 'ui/initialize', + params: { name: this.name, version: this.version } + }); + this.#send({ jsonrpc: '2.0', method: 'ui/notifications/initialized' }); + + this.#observePreferredTheme(); + setTimeout(async () => { + await awaitElementUpgrades(); + this.#observeUiSize(); + }, 0); + } + + callServerTool(args) { + return new Promise((resolve, reject) => { + const id = ++this.#nextId; + this.#handlers.set(id, { resolve, reject }); + this.#send({ jsonrpc: '2.0', id, method: 'tools/call', params: args }); + }); + } + + #send(msg) { + const expectedOrigin = this.#getExpectedParentOrigin(); + if (!expectedOrigin) return; + window.parent.postMessage(msg, expectedOrigin); + } + + #getExpectedParentOrigin() { + if (this.#expectedParentOrigin !== undefined) return this.#expectedParentOrigin; + const origin = window.location.ancestorOrigins?.[0] || (document.referrer ? new URL(document.referrer).origin : ''); + this.#expectedParentOrigin = this.#isAllowedParentOrigin(origin) ? origin : ''; + return this.#expectedParentOrigin; + } + + #isAllowedParentOrigin(origin) { + return typeof origin === 'string' && origin !== '' && origin !== 'null'; + } + + #observePreferredTheme() { + const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const apply = matches => { + document.documentElement.setAttribute('nve-theme', matches ? 'dark' : 'light'); + }; + apply(darkQuery.matches); + darkQuery.addEventListener('change', e => apply(e.matches)); + return darkQuery; + } + + #observeUiSize() { + const sizeObserver = new ResizeObserver(() => this.#reportUiSize()); + sizeObserver.observe(document.body); + this.#reportUiSize(); + return sizeObserver; + } + + #reportUiSize() { + const SIZE_CHANGE_THRESHOLD = 1; + const width = Math.ceil(document.body.scrollWidth); + const measuredHeight = Math.ceil(document.body.scrollHeight); + const resolvedMinHeight = hasNvePage() ? 580 : 200; + const height = Math.max(resolvedMinHeight, measuredHeight); + + const widthDelta = Math.abs(width - this.#lastReportedWidth); + const heightDelta = Math.abs(height - this.#lastReportedHeight); + if (widthDelta <= SIZE_CHANGE_THRESHOLD && heightDelta <= SIZE_CHANGE_THRESHOLD) return; + + this.#lastReportedWidth = width; + this.#lastReportedHeight = height; + this.#send({ jsonrpc: '2.0', method: 'ui/notifications/size-changed', params: { width, height } }); + } +} + +function hasNvePage(root = document.body) { + if (root.querySelector('nve-page')) return true; + return Array.from(root.querySelectorAll('*')).some(el => el.shadowRoot && hasNvePage(el.shadowRoot)); +} + +async function awaitElementUpgrades(root = document.body) { + const tags = new Set(); + root.querySelectorAll('*').forEach(el => { + const tag = el.tagName.toLowerCase(); + if (tag.includes('-')) tags.add(tag); + }); + await Promise.all(Array.from(tags).map(tag => customElements.whenDefined(tag).catch(() => {}))); +} diff --git a/projects/cli/src/mcp/ui/examples-render.js b/projects/cli/src/mcp/ui/examples-render.js new file mode 100644 index 000000000..b29ed0188 --- /dev/null +++ b/projects/cli/src/mcp/ui/examples-render.js @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +class ElementsExamplePreview extends HTMLElement { + constructor() { + super(); + this.state = { template: '', errorMessage: '', validationMessages: [] }; + } + + get template() { + return this.state.template; + } + + set template(value) { + this.state.template = typeof value === 'string' ? value : ''; + this.state.errorMessage = ''; + this.state.validationMessages = []; + this.#render(); + } + + set errorMessage(value) { + this.state.errorMessage = typeof value === 'string' ? value : 'Failed to load example.'; + this.state.validationMessages = []; + this.#render(); + } + + set validationMessages(value) { + this.state.validationMessages = Array.isArray(value) ? value : []; + this.state.errorMessage = ''; + this.#render(); + } + + #render() { + this.replaceChildren(); + if (this.state.errorMessage) { + this.append(this.#createMessage(this.state.errorMessage)); + return; + } + if (this.state.validationMessages.length) { + this.append(this.#createValidationMessages(this.state.validationMessages)); + return; + } + const fragment = document + .createRange() + .createContextualFragment(this.state.template || '
Example not found.
'); + this.append(fragment); + } + + #createMessage(message) { + const paragraph = document.createElement('pre'); + paragraph.textContent = message; + return paragraph; + } + + #createValidationMessages(messages) { + const pre = document.createElement('pre'); + pre.textContent = messages + .map(message => { + const location = message.line && message.column ? ' (' + message.line + ':' + message.column + ')' : ''; + return message.severity + ': ' + message.message + location; + }) + .join('\n'); + return pre; + } +} + +customElements.define('nve-mcp-examples-render', ElementsExamplePreview); + +const client = new Client({ name: 'Elements Example Preview', version: '1.0.0' }); + +const preview = document.createElement('nve-mcp-examples-render'); +let pendingId = null; +let pendingTemplate = null; + +function setPendingExample({ arguments: args } = {}) { + pendingId = null; + pendingTemplate = null; + if (args && typeof args.template === 'string') { + pendingTemplate = args.template; + } else if (args && typeof args.id === 'string') { + pendingId = args.id; + } +} + +function getStructuredResult(payload) { + const content = getStructuredContent(payload); + return content && content.result !== undefined ? content.result : content; +} + +function getStructuredContent(payload) { + if (!payload) return undefined; + if (payload.structuredContent) return payload.structuredContent; + if (payload.result && payload.result.structuredContent) return payload.result.structuredContent; + if (payload.result !== undefined) return payload.result; + return undefined; +} + +function getToolErrorMessage(payload) { + const content = getStructuredContent(payload); + return content && content.status === 'error' && typeof content.message === 'string' ? content.message : ''; +} + +function getExampleTemplate(payload) { + const example = getStructuredResult(payload); + return example && typeof example.template === 'string' ? example.template : ''; +} + +function getRenderResult(payload) { + const result = getStructuredResult(payload); + if (!result || typeof result.template !== 'string' || !Array.isArray(result.lintMessages)) return undefined; + return result; +} + +async function renderPendingExample(params) { + const renderResult = getRenderResult(params); + if (renderResult) { + if (renderResult.lintMessages.length) { + preview.validationMessages = renderResult.lintMessages; + return; + } + preview.template = renderResult.template; + return; + } + const errorMessage = getToolErrorMessage(params); + if (errorMessage) { + preview.errorMessage = errorMessage; + return; + } + if (typeof pendingTemplate === 'string') { + preview.errorMessage = 'Failed to validate template.'; + return; + } + if (!pendingId) return; + try { + const res = await client.callServerTool({ + name: 'examples_get', + arguments: { id: pendingId, format: 'json' } + }); + const exampleErrorMessage = getToolErrorMessage(res); + if (exampleErrorMessage) { + preview.errorMessage = exampleErrorMessage; + return; + } + preview.template = getExampleTemplate(res); + } catch (err) { + preview.errorMessage = 'Failed to load example: ' + (err && err.message ? err.message : 'unknown error'); + } +} + +client.ontoolinput = params => { + setPendingExample(params); +}; + +client.ontoolresult = params => { + void renderPendingExample(params); +}; + +client.connect(); +document.body.replaceChildren(preview); diff --git a/projects/cli/src/mcp/ui/index.test.ts b/projects/cli/src/mcp/ui/index.test.ts new file mode 100644 index 000000000..0b0f62090 --- /dev/null +++ b/projects/cli/src/mcp/ui/index.test.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { buildUIResourceHtml } from './index.js'; + +describe(buildUIResourceHtml.name, () => { + it('should validate inbound MCP UI message source and origin before reading data', () => { + const html = buildUIResourceHtml({ + title: 'Test MCP UI', + script: '' + }); + const messageListenerScript = html.slice( + html.indexOf("window.addEventListener('message'"), + html.indexOf('if (m.id !== undefined') + ); + + expect(messageListenerScript).toContain('const expectedOrigin = this.#getExpectedParentOrigin();'); + expect(messageListenerScript).toContain('e.origin !== expectedOrigin'); + expect(messageListenerScript).toContain('e.source !== window.parent'); + expect(messageListenerScript.indexOf('const expectedOrigin')).toBeLessThan( + messageListenerScript.indexOf('const m = e.data') + ); + }); +}); diff --git a/projects/cli/src/mcp/ui/index.ts b/projects/cli/src/mcp/ui/index.ts new file mode 100644 index 000000000..115d6c632 --- /dev/null +++ b/projects/cli/src/mcp/ui/index.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import coreBundle from '@nvidia-elements/core/bundles/index.js?raw'; +import codeBundle from '@nvidia-elements/code/bundles/index.js?raw'; +import themesIndexCss from '@nvidia-elements/themes/index.css?raw'; +import themesDarkCss from '@nvidia-elements/themes/dark.css?raw'; +import themesHighContrastCss from '@nvidia-elements/themes/high-contrast.css?raw'; +import stylesTypographyCss from '@nvidia-elements/styles/typography.css?raw'; +import stylesLayoutCss from '@nvidia-elements/styles/layout.css?raw'; +import clientScript from './client.js?raw'; +import iconsListScript from './api-icons-list.js?raw'; +import tokensListScript from './api-tokens-list.js?raw'; +import examplesRenderScript from './examples-render.js?raw'; + +export interface UIResource { + name: string; + mimeType: string; + resourceUri: string; + description: string; + getHtml: () => string; +} + +interface UIBundles { + code?: boolean; +} + +export const MCP_UI_MIME_TYPE = 'text/html;profile=mcp-app'; + +export const examplesRenderResource: UIResource = { + name: 'nve-mcp-examples-render', + mimeType: MCP_UI_MIME_TYPE, + resourceUri: 'ui://elements/example-preview', + description: 'Renders Elements component examples in a sandboxed iframe.', + getHtml: () => + buildUIResourceHtml({ + title: 'Elements Example Preview', + script: examplesRenderScript + }) +}; + +export const iconsListResource: UIResource = { + name: 'nve-mcp-api-icons-list', + mimeType: MCP_UI_MIME_TYPE, + resourceUri: 'ui://elements/icons-list', + description: 'Renders available Elements icons with copyable source markup.', + getHtml: () => + buildUIResourceHtml({ + title: 'Elements Icons List', + script: iconsListScript + }) +}; + +export const tokensListResource: UIResource = { + name: 'nve-mcp-api-tokens-list', + mimeType: MCP_UI_MIME_TYPE, + resourceUri: 'ui://elements/tokens-list', + description: 'Renders Elements design tokens with visual previews and copyable CSS references.', + getHtml: () => + buildUIResourceHtml({ + title: 'Elements Token Explorer', + script: tokensListScript + }) +}; + +export const uiResources: UIResource[] = [examplesRenderResource, iconsListResource, tokensListResource]; + +const getBundles = ({ code = false }: UIBundles = {}): string => { + const scripts = code ? [coreBundle, codeBundle] : [coreBundle]; + return scripts.map(script => /* html */ ``).join('\n'); +}; + +export function buildUIResourceHtml({ + title, + bundles, + script +}: { + title: string; + script: string; + bundles?: UIBundles; +}): string { + return /* html */ ` + + + + ${title} + + + + ${getBundles(bundles)} + + +`; +} diff --git a/projects/cli/src/types.d.ts b/projects/cli/src/types.d.ts index 1b8256ea2..721038419 100644 --- a/projects/cli/src/types.d.ts +++ b/projects/cli/src/types.d.ts @@ -5,3 +5,8 @@ declare module 'marked-terminal' { import type { MarkedExtension } from 'marked'; export function markedTerminal(options?: Record): MarkedExtension; } + +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/projects/cli/src/utils.test.ts b/projects/cli/src/utils.test.ts index a6f926689..5e1479c6c 100644 --- a/projects/cli/src/utils.test.ts +++ b/projects/cli/src/utils.test.ts @@ -20,7 +20,8 @@ import { isObjectLiteral, renderResult, statusIcons, - wrapUrl + wrapUrl, + progressBar } from './utils.js'; vi.mock('ora'); @@ -162,6 +163,22 @@ describe('utils', () => { await expect(runAsyncTool(args, mockFn)).rejects.toThrow('Test error'); expect(mockFn).toHaveBeenCalledWith(args); }); + + it('should append console and progress output to the spinner text', async () => { + vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(1000); + const args = {}; + mockFn.mockImplementationOnce(async value => { + console.log('console update'); + (value.onProgress as (message: string) => void)('progress update'); + return 'test result'; + }); + + const result = await runAsyncTool(args, mockFn); + + expect(result).toBe('test result'); + expect(mockSpinner.text).toContain('console update'); + expect(mockSpinner.text).toContain('progress update'); + }); }); describe('getArgValue', async () => { @@ -366,6 +383,18 @@ describe('utils', () => { }); }); + describe('progressBar', () => { + it('should render a clamped progress bar with default width', () => { + expect(progressBar(50)).toBe('██████████░░░░░░░░░░'); + expect(progressBar(-10)).toBe('░░░░░░░░░░░░░░░░░░░░'); + expect(progressBar(110)).toBe('████████████████████'); + }); + + it('should respect custom width', () => { + expect(progressBar(25, 8)).toBe('██░░░░░░'); + }); + }); + describe('isReport', () => { it('should return true for valid report objects', () => { const validReport = { diff --git a/projects/cli/vitest.config.ts b/projects/cli/vitest.config.ts index 02afeeec7..aa4f66030 100644 --- a/projects/cli/vitest.config.ts +++ b/projects/cli/vitest.config.ts @@ -10,6 +10,7 @@ export default mergeConfig(libraryNodeTestConfig, { test: { include: ['./src/**/*.test.ts'], coverage: { + exclude: ['src/mcp/ui/*.js'], thresholds: { lines: 90, branches: 90, diff --git a/projects/code/src/codeblock/codeblock.test.lighthouse.ts b/projects/code/src/codeblock/codeblock.test.lighthouse.ts index 42b6df16f..2fac6ec16 100644 --- a/projects/code/src/codeblock/codeblock.test.lighthouse.ts +++ b/projects/code/src/codeblock/codeblock.test.lighthouse.ts @@ -18,6 +18,6 @@ describe('codeblock lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(19.6); + expect(report.payload.javascript.kb).toBeLessThan(19.7); }); }); diff --git a/projects/core/src/button/button.css b/projects/core/src/button/button.css index 8cecce220..826530a29 100644 --- a/projects/core/src/button/button.css +++ b/projects/core/src/button/button.css @@ -144,7 +144,7 @@ --color: var(--nve-sys-text-white-color); } -:host(:hover:not([interaction], [disabled], [readonly])) { +:host(:hover:not([interaction], [disabled], [readonly], [pressed])) { --color: var(--nve-sys-interaction-hover-color); } diff --git a/projects/core/src/internal/services/global.utils.ts b/projects/core/src/internal/services/global.utils.ts index 43c4ec202..fd0fb2d13 100644 --- a/projects/core/src/internal/services/global.utils.ts +++ b/projects/core/src/internal/services/global.utils.ts @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getEsmHostedWarning } from '../utils/audit-logs.js'; - declare global { var process: { env?: { NODE_ENV?: string } } | undefined; } @@ -22,11 +20,6 @@ export function getEnv(): 'development' | 'production' { export function getHostDetails(): { moduleHost: string; pageHost: string } { const pageHost = globalThis.location?.hostname; const moduleHost = new URL(import.meta.url).hostname; - const isEsmHosted = moduleHost.startsWith('esm.'); - - if (isEsmHosted && pageHost !== 'localhost' && !pageHost.includes('esm.')) { - console.warn(getEsmHostedWarning()); - } return { pageHost, diff --git a/projects/core/src/internal/utils/audit-logs.ts b/projects/core/src/internal/utils/audit-logs.ts index a78ad1976..173d8b83a 100644 --- a/projects/core/src/internal/utils/audit-logs.ts +++ b/projects/core/src/internal/utils/audit-logs.ts @@ -36,7 +36,3 @@ export function getDuplicatePackageVersionWarning(localName: string, version: st export function getDuplicatePackageGlobalVersionWarning() { return `@nve: Multiple versions of Elements loaded, please check for duplicate package versions. ${DOCS_LOG_URL}#duplicate-package-version`; } - -export function getEsmHostedWarning() { - return '@nve: Using esm.sh is not supported for production use.'; -} diff --git a/projects/internals/tools/src/api/service.test.ts b/projects/internals/tools/src/api/service.test.ts index df282a291..ca2f6c7ca 100644 --- a/projects/internals/tools/src/api/service.test.ts +++ b/projects/internals/tools/src/api/service.test.ts @@ -121,25 +121,22 @@ describe('ApiService', () => { expect(result[0].name).toBe('nve-button'); }); - it('should return helpful message when all names are not found', async () => { - const result = await ApiService.get({ - names: ['nve-nonexistent-abc', 'nve-nonexistent-xyz'], - format: 'markdown' - }); - expect(typeof result).toBe('string'); - expect(result as string).toContain('No components or APIs found matching'); - expect(result as string).toContain('nve-nonexistent-abc'); - expect(result as string).toContain('nve-nonexistent-xyz'); - expect(result as string).toContain('Tip:'); + it('should reject when all names are not found', async () => { + await expect( + ApiService.get({ + names: ['nve-nonexistent-abc', 'nve-nonexistent-xyz'], + format: 'markdown' + }) + ).rejects.toThrow('No components or APIs found matching "nve-nonexistent-abc", "nve-nonexistent-xyz".'); }); - it('should return helpful message when all names are not found (json)', async () => { - const result = await ApiService.get({ - names: ['nve-nonexistent-abc'], - format: 'json' - }); - expect(typeof result).toBe('string'); - expect(result as string).toContain('No components or APIs found matching'); + it('should reject when all names are not found (json)', async () => { + await expect( + ApiService.get({ + names: ['nve-nonexistent-abc'], + format: 'json' + }) + ).rejects.toThrow('No components or APIs found matching "nve-nonexistent-abc".'); }); }); @@ -212,6 +209,7 @@ describe('ApiService', () => { const result = await MockedApiService.templateValidate({ template: 'Test' }); expect(Array.isArray(result)).toBe(true); + expect(mockLintTemplate).toHaveBeenCalledWith('Test', { strict: false }); vi.doUnmock('@nvidia-elements/lint/eslint/internals'); }); @@ -227,6 +225,7 @@ describe('ApiService', () => { const result = await MockedApiService.templateValidate({ template: 'Test' }); expect(Array.isArray(result)).toBe(true); + expect(mockLintTemplate).toHaveBeenCalledWith('Test', { strict: false }); vi.doUnmock('@nvidia-elements/lint/eslint/internals'); }); }); @@ -263,6 +262,10 @@ describe('ApiService', () => { expect((ApiService.tokensList as ToolMethod).metadata.summary).toBe( 'Get available semantic CSS custom properties / design tokens for theming.' ); + expect((ApiService.tokensList as ToolMethod).metadata.inputSchema?.properties?.query).toBeDefined(); + expect((ApiService.tokensList as ToolMethod).metadata.app?.resourceUri).toBe( + 'ui://elements/tokens-list' + ); }); it('should provide list tool', async () => { @@ -270,6 +273,18 @@ describe('ApiService', () => { expect(result).toBeDefined(); expect(result).toContain('## CSS Variables'); }); + + it('should filter tokens by query', async () => { + const result = (await ApiService.tokensList({ format: 'json', query: 'shadow' })) as { + name: string; + value: string; + description: string; + }[]; + expect(result.length).toBeGreaterThan(0); + expect( + result.every(token => [token.name, token.value, token.description].join(' ').toLowerCase().includes('shadow')) + ).toBe(true); + }); }); describe('iconsList', () => { @@ -279,6 +294,7 @@ describe('ApiService', () => { expect((ApiService.iconsList as ToolMethod).metadata.summary).toBe( 'Get list of all available icon names for nve-icon and nve-icon-button.' ); + expect((ApiService.iconsList as ToolMethod).metadata.app?.resourceUri).toBe('ui://elements/icons-list'); }); it('should return markdown with all icon names', async () => { diff --git a/projects/internals/tools/src/api/service.ts b/projects/internals/tools/src/api/service.ts index 0a8b41e7a..0f35dcb19 100644 --- a/projects/internals/tools/src/api/service.ts +++ b/projects/internals/tools/src/api/service.ts @@ -122,7 +122,7 @@ export class ApiService { const notFound = results.filter((r: Element | Attribute | string): r is string => typeof r === 'string'); if (found.length === 0) { - return `No components or APIs found matching "${notFound.join('", "')}".\n\n${listToolHelpfulTip}`; + throw new Error(`No components or APIs found matching "${notFound.join('", "')}".\n\n${listToolHelpfulTip}`); } if (format === 'json') { @@ -162,12 +162,8 @@ export class ApiService { } }) static async templateValidate({ template }: { template: string }): Promise { - if (process.env.ELEMENTS_ENV === 'mcp' || process.env.ELEMENTS_ENV === 'cli') { - const { lintTemplate } = await import('@nvidia-elements/lint/eslint/internals'); - return await lintTemplate(template); - } else { - return []; - } + const { lintTemplate } = await import('@nvidia-elements/lint/eslint/internals'); + return await lintTemplate(template, { strict: false }); } @tool({ @@ -192,6 +188,7 @@ export class ApiService { } @tool({ + app: { resourceUri: 'ui://elements/tokens-list' }, summary: 'Get available semantic CSS custom properties / design tokens for theming.', inputSchema: { type: 'object', @@ -201,6 +198,10 @@ export class ApiService { description: markdownDescription, enum: ['markdown', 'json'], default: 'markdown' + }, + query: { + type: 'string', + description: 'Optional search query used to filter tokens by name, value, or description before rendering.' } }, additionalProperties: false @@ -225,13 +226,14 @@ export class ApiService { } }) static async tokensList( - { format }: { format: 'markdown' | 'json' } = { format: 'markdown' } - ): Promise<{ name: string; description: string }[] | string> { + { format, query }: { format: 'markdown' | 'json'; query?: string } = { format: 'markdown' } + ): Promise<{ name: string; value: string; description: string }[] | string> { const apis = await MetadataApiService.getData(); - return getContextTokens(format, apis.data.tokens) ?? ''; + return getContextTokens(format, apis.data.tokens, { query }) ?? ''; } @tool({ + app: { resourceUri: 'ui://elements/icons-list' }, summary: 'Get list of all available icon names for nve-icon and nve-icon-button.', inputSchema: { type: 'object', diff --git a/projects/internals/tools/src/api/utils.test.ts b/projects/internals/tools/src/api/utils.test.ts index 218ebcb1f..dbd4b5d5b 100644 --- a/projects/internals/tools/src/api/utils.test.ts +++ b/projects/internals/tools/src/api/utils.test.ts @@ -350,6 +350,21 @@ describe('getContextTokens', () => { const result = getContextTokens('json', tokens) as Token[]; expect(result).toHaveLength(3); }); + + it('should filter tokens by query across name, value, and description', () => { + const tokens = [ + createToken('sys-color-primary', '#007bff', 'Primary action background'), + createToken('sys-spacing-md', '16px', 'Comfortable layout gap'), + createToken('sys-font-size-lg', '24px', 'Large heading text') + ]; + const nameResult = getContextTokens('json', tokens, { query: 'spacing' }) as Token[]; + const valueResult = getContextTokens('json', tokens, { query: '24px' }) as Token[]; + const descriptionResult = getContextTokens('json', tokens, { query: 'ACTION' }) as Token[]; + + expect(nameResult.map(token => token.name)).toEqual(['sys-spacing-md']); + expect(valueResult.map(token => token.name)).toEqual(['sys-font-size-lg']); + expect(descriptionResult.map(token => token.name)).toEqual(['sys-color-primary']); + }); }); describe('sorting', () => { diff --git a/projects/internals/tools/src/api/utils.ts b/projects/internals/tools/src/api/utils.ts index ca5cf521b..01dd299d8 100644 --- a/projects/internals/tools/src/api/utils.ts +++ b/projects/internals/tools/src/api/utils.ts @@ -59,8 +59,22 @@ export async function searchContextAPIs(query: string, config: { limit?: number return config.limit !== undefined ? data.slice(0, config.limit) : data; } -export function getContextTokens(format: 'markdown' | 'json', tokens: Token[]): string | Token[] | undefined { - const filteredTokens = distillTokens(tokens); +interface TokenFilterOptions { + query?: string; +} + +function tokenMatchesQuery(token: Token, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + return [token.name, token.value, token.description].some(value => value.toLowerCase().includes(normalizedQuery)); +} + +export function getContextTokens( + format: 'markdown' | 'json', + tokens: Token[], + { query = '' }: TokenFilterOptions = {} +): string | Token[] | undefined { + const filteredTokens = distillTokens(tokens).filter(token => tokenMatchesQuery(token, query)); if (format === 'markdown') { return `## CSS Variables\n\nAvailable semantic design tokens for theming. diff --git a/projects/internals/tools/src/distill/examples.ts b/projects/internals/tools/src/distill/examples.ts index 098fd37b7..442d14183 100644 --- a/projects/internals/tools/src/distill/examples.ts +++ b/projects/internals/tools/src/distill/examples.ts @@ -27,14 +27,18 @@ function passesExclusionChecks(example: Partial) { } function passesInclusionChecks(example: Partial) { - if (example.composition) { - return true; - } + const { summary, composition } = example; const id = example.id ?? ''; const tags = example.tags ?? []; + const isDefault = !id.includes('-'); // default is only the element/api name, no suffix + + if (composition || isDefault) { + return true; + } + return ( (includedIdPatterns.some(p => id.includes(p)) || tags.includes('pattern') || tags.includes('template')) && - (example.summary?.length ?? 0) > 0 + (summary?.length ?? 0) > 0 ); } diff --git a/projects/internals/tools/src/examples/service.test.ts b/projects/internals/tools/src/examples/service.test.ts index b330123c6..2b57f4bd7 100644 --- a/projects/internals/tools/src/examples/service.test.ts +++ b/projects/internals/tools/src/examples/service.test.ts @@ -1,13 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Example } from '@internals/metadata'; import { ExamplesService } from './service.js'; import { getContextExamples } from './utils.js'; -import type { ToolMethod } from '../internal/tools.js'; +import { loadTools, type ToolMethod, type ToolOutput } from '../internal/tools.js'; +import { ApiService } from '../api/service.js'; describe('ExampleService', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should provide list tool', async () => { const result = await ExamplesService.list(); expect(result).toBeDefined(); @@ -80,14 +85,31 @@ describe('ExampleService', () => { expect(result.id.toLowerCase()).toBe(examples[0].id.toLowerCase()); }); - it('should return not-found message for unknown example id (markdown)', async () => { - const result = await ExamplesService.get({ id: 'nonexistent-example-xyz', format: 'markdown' }); - expect(result).toBe('Example not found. Use the list tool to get a list of all available examples and patterns.'); + it('should reject unknown example id (markdown)', async () => { + await expect(ExamplesService.get({ id: 'nonexistent-example-xyz', format: 'markdown' })).rejects.toThrow( + 'Unknown example "nonexistent-example-xyz". Use the examples_list tool to get a list of all available examples.' + ); }); - it('should return undefined for unknown example id (json)', async () => { - const result = await ExamplesService.get({ id: 'nonexistent-example-xyz', format: 'json' }); - expect(result).toBeUndefined(); + it('should reject unknown example id (json)', async () => { + await expect(ExamplesService.get({ id: 'nonexistent-example-xyz', format: 'json' })).rejects.toThrow( + 'Unknown example "nonexistent-example-xyz". Use the examples_list tool to get a list of all available examples.' + ); + }); + + it('should return a managed tool error for unknown example id', async () => { + const tools = loadTools(ExamplesService); + const getTool = tools.find(tool => tool.metadata.name === 'get'); + + const result = (await getTool?.({ id: 'nonexistent-example-xyz', format: 'json' })) as ToolOutput< + Example | undefined + >; + + expect(result.status).toBe('error'); + expect(result.message).toBe( + 'Unknown example "nonexistent-example-xyz". Use the examples_list tool to get a list of all available examples.' + ); + expect(result.result).toBeUndefined(); }); it('should provide getAll tool for internal usage', async () => { @@ -101,4 +123,70 @@ describe('ExampleService', () => { expect(result).toContain('nonexistent-example-xyz'); expect(result).toContain('Tip:'); }); + + it('should provide render tool that echoes a validated template', async () => { + const template = 'hello'; + const result = await ExamplesService.render({ template, name: 'Hello button' }); + expect(result.template).toBe(template); + expect(result.name).toBe('Hello button'); + expect(Array.isArray(result.lintMessages)).toBe(true); + expect((ExamplesService.render as ToolMethod).metadata.name).toBe('render'); + expect((ExamplesService.render as ToolMethod).metadata.app?.resourceUri).toBe( + 'ui://elements/example-preview' + ); + expect((ExamplesService.render as ToolMethod).metadata.inputSchema?.required).toContain('template'); + }); + + it('should accept render tool without an explicit name', async () => { + const result = await ExamplesService.render({ template: 'x' }); + expect(result.template).toBe('x'); + expect(result.name).toBeUndefined(); + }); + + it('should reject render tool templates with lint messages', async () => { + vi.spyOn(ApiService, 'templateValidate').mockResolvedValue([ + { + id: 'invalid-template', + severity: 'error', + message: 'Invalid template.', + suggestions: [], + line: 1, + column: 2, + endLine: 1, + endColumn: 3 + } + ]); + + await expect(ExamplesService.render({ template: '' })).rejects.toThrow( + 'Template validation failed.' + ); + }); + + it('should return a managed tool error when render validation fails', async () => { + vi.spyOn(ApiService, 'templateValidate').mockResolvedValue([ + { + id: 'invalid-template', + severity: 'error', + message: 'Invalid template.', + suggestions: [], + line: 1, + column: 2, + endLine: 1, + endColumn: 3 + } + ]); + const tools = loadTools(ExamplesService); + const renderTool = tools.find(tool => tool.metadata.name === 'render'); + + const result = (await renderTool?.({ template: '' })) as ToolOutput<{ + template: string; + lintMessages: { message: string }[]; + }>; + + expect(result.status).toBe('error'); + expect(result.message).toBe('Template validation failed.'); + expect(result.result?.template).toBe(''); + expect(result.result?.lintMessages).toHaveLength(1); + expect(result.result?.lintMessages[0]?.message).toBe('Unexpected use of unknown tag '); + }); }); diff --git a/projects/internals/tools/src/examples/service.ts b/projects/internals/tools/src/examples/service.ts index b99912a0a..c5cc4b5e3 100644 --- a/projects/internals/tools/src/examples/service.ts +++ b/projects/internals/tools/src/examples/service.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { ExamplesService as ExamplesServiceMetadata, type Example } from '@internals/metadata'; -import { service, tool, ToolSupport } from '../internal/tools.js'; +import type { TemplateLintMessage } from '@nvidia-elements/lint/eslint/internals'; +import { service, tool, ToolError, ToolSupport } from '../internal/tools.js'; import { getContextExamples, renderExampleMarkdown, searchContextExamples } from './utils.js'; import { markdownDescription } from '../internal/utils.js'; +import { eslintSchema } from '../internal/schema.js'; const MAX_RESULT_LIMIT = 5; @@ -58,6 +60,7 @@ export class ExamplesService { } @tool({ + app: { resourceUri: 'ui://elements/example-preview' }, summary: 'Get the full template of a known example or pattern by id.', inputSchema: { type: 'object', @@ -80,18 +83,19 @@ export class ExamplesService { oneOf: [{ type: 'string' }, { type: 'object', additionalProperties: true }] } }) - static async get({ id, format }: { id: string; format: 'markdown' | 'json' }): Promise { + static async get({ id, format }: { id: string; format: 'markdown' | 'json' }): Promise { const results = (await getContextExamples('json', await ExamplesServiceMetadata.getData())) as Example[]; const found = results.find(r => r.id.toLocaleLowerCase() === id.toLowerCase()); + if (!found) { + throw new Error(`Unknown example "${id}". Use the examples_list tool to get a list of all available examples.`); + } + if (format === 'json') { return found; } - const markdown = found?.template - ? renderExampleMarkdown(found) - : 'Example not found. Use the list tool to get a list of all available examples and patterns.'; - return markdown; + return renderExampleMarkdown(found); } @tool({ @@ -144,6 +148,56 @@ export class ExamplesService { return await searchContextExamples(query, { format, limit: MAX_RESULT_LIMIT }); } + @tool({ + app: { resourceUri: 'ui://elements/example-preview' }, + support: ToolSupport.MCP, + summary: 'Render a custom Elements (nve-*) HTML template inline in the chat UI.', + description: `Render an ad-hoc Elements (nve-*) HTML template in the MCP App preview iframe. The template is linted against Elements APIs before rendering. Lint failures return a tool error instead of rendering the template. Use this to preview compositions you author. Templates should be self-contained HTML — the iframe loads the Elements bundle and themes automatically.`, + inputSchema: { + type: 'object', + properties: { + template: { + type: 'string', + description: 'HTML template to render. Should use Elements (nve-*) components.' + }, + name: { + type: 'string', + description: 'Optional human-readable label for the preview.' + } + }, + required: ['template'], + additionalProperties: false + }, + outputSchema: { + type: 'object', + properties: { + template: { type: 'string' }, + name: { type: 'string' }, + lintMessages: { + type: 'array', + items: eslintSchema, + description: 'Lint messages. Empty on success; non-empty when returned with status error.' + } + }, + additionalProperties: false, + required: ['template', 'lintMessages'] + } + }) + static async render({ + template, + name + }: { + template: string; + name?: string; + }): Promise<{ template: string; name?: string; lintMessages: TemplateLintMessage[] }> { + const { lintTemplate } = await import('@nvidia-elements/lint/eslint/internals'); + const lintMessages = await lintTemplate(template, { strict: true }); + if (lintMessages.length) { + throw new ToolError('Template validation failed.', { template, name, lintMessages }); + } + return { template, name, lintMessages }; + } + static async getAll(): Promise { return ExamplesServiceMetadata.getData(); } diff --git a/projects/internals/tools/src/index.test.ts b/projects/internals/tools/src/index.test.ts index 7462e7044..9882c2833 100644 --- a/projects/internals/tools/src/index.test.ts +++ b/projects/internals/tools/src/index.test.ts @@ -1,15 +1,48 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; -import { tools } from './index.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ManagedToolMethod } from './internal/tools.js'; + +const originalPlaygroundBaseUrl = process.env.ELEMENTS_PLAYGROUND_BASE_URL; + +async function getTools(): Promise[]> { + vi.resetModules(); + const { tools } = await import('./index.js'); + return tools; +} + +async function getToolNames(): Promise { + return (await getTools()).map(tool => tool.metadata.toolName); +} describe('tools', () => { - it('should be defined', () => { - expect(tools).toBeDefined(); + afterEach(() => { + if (originalPlaygroundBaseUrl === undefined) { + delete process.env.ELEMENTS_PLAYGROUND_BASE_URL; + } else { + process.env.ELEMENTS_PLAYGROUND_BASE_URL = originalPlaygroundBaseUrl; + } + vi.resetModules(); + }); + + it('should be defined', async () => { + await expect(getToolNames()).resolves.toBeDefined(); }); - it('should include skills tools', () => { + it('should omit playground tools without runtime playground url configuration', async () => { + delete process.env.ELEMENTS_PLAYGROUND_BASE_URL; + await expect(getToolNames()).resolves.not.toContain('playground_create'); + }); + + it('should include playground tools with runtime playground url configuration', async () => { + process.env.ELEMENTS_PLAYGROUND_BASE_URL = 'https://playground.example.com'; + await expect(getToolNames()).resolves.toContain('playground_create'); + }); + + it('should include skills tools', async () => { + const tools = await getTools(); + expect(tools.some(tool => tool.metadata.command === 'skills.list')).toBe(true); expect(tools.some(tool => tool.metadata.command === 'skills.get')).toBe(true); }); diff --git a/projects/internals/tools/src/index.ts b/projects/internals/tools/src/index.ts index 826715dd0..dadbaa465 100644 --- a/projects/internals/tools/src/index.ts +++ b/projects/internals/tools/src/index.ts @@ -10,8 +10,6 @@ import { CliService } from './cli/service.js'; import { SkillsService } from './skills/service.js'; import { loadTools } from './internal/tools.js'; -declare const __ELEMENTS_PLAYGROUND_BASE_URL__: string; - export const VERSION = '0.0.0'; export { @@ -20,7 +18,10 @@ export { type ManagedToolMethod, type Schema, type ToolSupportFlags, + type ToolApp, + type ToolMetadata, ToolSupport, + ToolError, jsonSchemaToZod } from './internal/tools.js'; @@ -31,7 +32,7 @@ export { type Report, type ReportCheck } from './internal/types.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const services: any[] = [ApiService, CliService, ExamplesService, ProjectService, PackagesService, SkillsService]; -if (__ELEMENTS_PLAYGROUND_BASE_URL__) { +if (process.env.ELEMENTS_PLAYGROUND_BASE_URL) { services.push(PlaygroundService); } diff --git a/projects/internals/tools/src/internal/tools.test.ts b/projects/internals/tools/src/internal/tools.test.ts index 44c412888..e10803a9a 100644 --- a/projects/internals/tools/src/internal/tools.test.ts +++ b/projects/internals/tools/src/internal/tools.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; -import { loadTools, service, tool, jsonSchemaToZod, ToolSupport } from './tools.js'; +import { loadTools, service, tool, jsonSchemaToZod, ToolError, ToolSupport } from './tools.js'; import type { ToolMethod, ToolOutput, Schema } from './tools.js'; describe('metadata', () => { @@ -179,6 +179,27 @@ describe('loadTools', () => { expect(result).toBe(undefined); }); + it('should include structured result from a tool error', async () => { + @service() + class TestService { + @tool({ summary: 'test' }) + static foo() { + return new Promise(() => { + throw new ToolError('error message', [{ message: 'lint error' }]); + }); + } + } + + loadTools(TestService); + + const { result, status, message } = (await TestService['tool_foo']()) as unknown as ToolOutput< + { message: string }[] + >; + expect(status).toBe('error'); + expect(message).toBe('error message'); + expect(result).toEqual([{ message: 'lint error' }]); + }); + it('should load multiple tools from a class', () => { @service() class TestService { diff --git a/projects/internals/tools/src/internal/tools.ts b/projects/internals/tools/src/internal/tools.ts index 7cecc8382..9d9213af5 100644 --- a/projects/internals/tools/src/internal/tools.ts +++ b/projects/internals/tools/src/internal/tools.ts @@ -19,7 +19,7 @@ export const ToolSupport = { export type ToolSupportFlags = number; -interface ToolMetadata { +export interface ToolMetadata { inputSchema?: Schema; outputSchema?: Schema; summary: string; @@ -30,6 +30,11 @@ interface ToolMetadata { command: string; toolName: string; support: ToolSupportFlags; + app?: ToolApp; +} + +export interface ToolApp { + resourceUri: string; } export interface Schema { @@ -67,7 +72,17 @@ export type ToolMethod = { export interface ToolOutput { status: 'complete' | 'error'; message?: string; - result: T; + result?: T; +} + +export class ToolError extends Error { + constructor( + message: string, + readonly result?: T + ) { + super(message); + this.name = 'ToolError'; + } } /** @@ -94,7 +109,8 @@ export function tool({ annotations, inputSchema, outputSchema, - support + support, + app }: { summary: string; description?: string; @@ -102,6 +118,7 @@ export function tool({ inputSchema?: Schema; outputSchema?: Schema; support?: ToolSupportFlags; + app?: ToolApp; }) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (originalMethod: ToolMethod, _context: ClassMethodDecoratorContext) { @@ -119,7 +136,8 @@ export function tool({ outputSchema, name: originalMethod.name, title: originalMethod.name.replace(/([A-Z])/g, ' $1').trim(), - command + command, + app }; Object.assign(originalMethod, { metadata }); return originalMethod; @@ -146,7 +164,11 @@ export function loadTools(obj: any): ManagedToolMethod[] { try { return { status: 'complete', message: '', result: await obj[name](...args) }; } catch (e) { - return { status: 'error', message: (e as Error).message }; + return { + status: 'error', + message: e instanceof Error ? e.message : String(e), + result: e instanceof ToolError ? e.result : undefined + }; } }; obj[toolName].metadata = obj[name].metadata; diff --git a/projects/internals/tools/src/playground/service.test.ts b/projects/internals/tools/src/playground/service.test.ts index a3ec20e80..66e4ea009 100644 --- a/projects/internals/tools/src/playground/service.test.ts +++ b/projects/internals/tools/src/playground/service.test.ts @@ -5,7 +5,7 @@ import { writeFileSync, unlinkSync, mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { ToolMethod } from '../internal/tools.js'; +import { loadTools, type ToolMethod, type ToolOutput } from '../internal/tools.js'; import { PlaygroundService } from './service.js'; import { createPlaygroundURL } from './utils.js'; @@ -80,7 +80,7 @@ describe('PlaygroundService', () => { expect((PlaygroundService.create as ToolMethod).metadata.name).toBe('create'); expect((PlaygroundService.create as ToolMethod).metadata.command).toBe('create'); expect((PlaygroundService.create as ToolMethod).metadata.description).toBe( - 'Create a shareable playground URL from an HTML template. Returns URL if valid, or validation errors if invalid. Tip: Use playground_validate first to check for issues.' + 'Create a shareable playground URL from an HTML template. Returns URL if valid. Lint failures return a tool error. Tip: Use playground_validate first to check for issues.' ); expect((PlaygroundService.create as ToolMethod).metadata.inputSchema?.properties?.template).toBeDefined(); }); @@ -96,27 +96,56 @@ describe('PlaygroundService', () => { process.env.ELEMENTS_ENV = originalEnv; }); - it('should return lint errors when template has validation errors in mcp environment', async () => { + it('should reject templates with validation errors in mcp environment', async () => { process.env.ELEMENTS_ENV = 'mcp'; - const result = await PlaygroundService.create({ - template: 'hello', - start: false + await expect( + PlaygroundService.create({ + template: 'hello', + start: false + }) + ).rejects.toMatchObject({ + message: 'Template validation failed', + result: expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Unexpected use of restricted attribute "nve-layout"') + }) + ]) }); - expect(Array.isArray(result)).toBe(true); - expect((result as unknown[]).length).toBeGreaterThan(0); - expect((result as { message: string }[])[0].message).toContain( - 'Unexpected use of restricted attribute "nve-layout" on . Remove the attribute.' - ); }); - it('should return lint errors when template has validation errors in cli environment', async () => { + it('should reject templates with validation errors in cli environment', async () => { process.env.ELEMENTS_ENV = 'cli'; - const result = await PlaygroundService.create({ + await expect( + PlaygroundService.create({ + template: 'hello', + start: false + }) + ).rejects.toMatchObject({ + message: 'Template validation failed', + result: expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Unexpected use of restricted attribute "nve-layout"') + }) + ]) + }); + }); + + it('should return a managed tool error when create validation fails', async () => { + process.env.ELEMENTS_ENV = 'mcp'; + const tools = loadTools(PlaygroundService); + const createTool = tools.find(tool => tool.metadata.name === 'create'); + + const result = (await createTool?.({ template: 'hello', start: false - }); - expect(Array.isArray(result)).toBe(true); - expect((result as unknown[]).length).toBeGreaterThan(0); + })) as ToolOutput<{ message: string }[]>; + + expect(result.status).toBe('error'); + expect(result.message).toBe('Template validation failed'); + expect(result.result).toHaveLength(2); + expect(result.result?.[0]?.message).toContain( + 'Unexpected use of restricted attribute "nve-layout" on . Remove the attribute.' + ); }); it('should skip validation and return URL when not in mcp or cli environment', async () => { @@ -270,13 +299,11 @@ describe('PlaygroundService', () => { } }); - it('create should return lint errors from file path in mcp environment', async () => { + it('create should reject lint errors from file path in mcp environment', async () => { process.env.ELEMENTS_ENV = 'mcp'; writeFileSync(tempFile, 'hello', 'utf8'); - const result = await PlaygroundService.create({ path: tempFile, start: false }); - expect(Array.isArray(result)).toBe(true); - expect((result as { message: string }[])[0].message).toContain( - 'Unexpected use of restricted attribute "nve-layout"' + await expect(PlaygroundService.create({ path: tempFile, start: false })).rejects.toThrow( + 'Template validation failed' ); }); diff --git a/projects/internals/tools/src/playground/service.ts b/projects/internals/tools/src/playground/service.ts index 63a5951cc..a857a2842 100644 --- a/projects/internals/tools/src/playground/service.ts +++ b/projects/internals/tools/src/playground/service.ts @@ -10,7 +10,7 @@ import { playgroundTypes, resolveTemplate } from './utils.js'; -import { service, tool } from '../internal/tools.js'; +import { service, tool, ToolError } from '../internal/tools.js'; import { ELEMENTS_ENV_ICON } from '../internal/utils.js'; import { eslintSchema } from '../internal/schema.js'; @@ -58,8 +58,8 @@ export class PlaygroundService { const templateContent = await resolveTemplate({ template, path }); if (process.env.ELEMENTS_ENV === 'mcp' || process.env.ELEMENTS_ENV === 'cli') { - const { lintPlaygroundTemplate } = await import('@nvidia-elements/lint/eslint/internals'); - return await lintPlaygroundTemplate(templateContent); + const { lintTemplate } = await import('@nvidia-elements/lint/eslint/internals'); + return await lintTemplate(templateContent, { strict: true }); } else { return []; } @@ -68,7 +68,7 @@ export class PlaygroundService { @tool({ summary: 'Create a shareable playground URL from an HTML template.', description: - 'Create a shareable playground URL from an HTML template. Returns URL if valid, or validation errors if invalid. Tip: Use playground_validate first to check for issues.', + 'Create a shareable playground URL from an HTML template. Returns URL if valid. Lint failures return a tool error. Tip: Use playground_validate first to check for issues.', inputSchema: { type: 'object', properties: { @@ -107,7 +107,7 @@ export class PlaygroundService { { type: 'array', items: eslintSchema, - description: 'Template errors requiring correction.', + description: 'Template errors requiring correction when returned with status error.', additionalProperties: false } ] @@ -119,15 +119,15 @@ export class PlaygroundService { name, type, author - }: PlaygroundOptions & { author?: string }): Promise { + }: PlaygroundOptions & { author?: string }): Promise { const templateContent = await resolveTemplate({ template, path }); if (process.env.ELEMENTS_ENV === 'mcp' || process.env.ELEMENTS_ENV === 'cli') { - const { lintPlaygroundTemplate } = await import('@nvidia-elements/lint/eslint/internals'); - const lintResult = await lintPlaygroundTemplate(templateContent); + const { lintTemplate } = await import('@nvidia-elements/lint/eslint/internals'); + const lintResult = await lintTemplate(templateContent, { strict: true }); if (lintResult.length > 0) { - return lintResult; + throw new ToolError('Template validation failed', lintResult); } } diff --git a/projects/internals/tools/src/playground/utils.ts b/projects/internals/tools/src/playground/utils.ts index 2883f8390..153245a24 100644 --- a/projects/internals/tools/src/playground/utils.ts +++ b/projects/internals/tools/src/playground/utils.ts @@ -7,8 +7,8 @@ import type { Element } from '@internals/metadata'; import { getElementImports } from '../internal/utils.js'; import { validateTemplate } from '../internal/validate.js'; -declare const __ELEMENTS_PLAYGROUND_BASE_URL__: string; -declare const __ELEMENTS_ESM_CDN_BASE_URL__: string; +const ELEMENTS_PLAYGROUND_BASE_URL = process.env.ELEMENTS_PLAYGROUND_BASE_URL ?? ''; +const ELEMENTS_ESM_CDN_BASE_URL = process.env.ELEMENTS_ESM_CDN_BASE_URL ?? ''; interface PlaygroundOptions { type?: PlaygroundType; @@ -146,9 +146,9 @@ export function createVueFiles(content: string, elements: Element[], options: Pl function createURL(files: string, options: PlaygroundOptions) { const defaultOptions = { openFile: 'index.html', ...options }; - return __ELEMENTS_PLAYGROUND_BASE_URL__?.length > 0 + return ELEMENTS_PLAYGROUND_BASE_URL.length > 0 ? encodeURI( - `${__ELEMENTS_PLAYGROUND_BASE_URL__}/?version=1&layout=vertical-split${defaultOptions.name ? `&name=${defaultOptions.name.trim()}` : ''}${defaultOptions.theme ? `&theme=${defaultOptions.theme}` : ''}&file=${defaultOptions.openFile}${defaultOptions.referer ? `&ref=${defaultOptions.referer}` : ''}&files=${files}` + `${ELEMENTS_PLAYGROUND_BASE_URL}/?version=1&layout=vertical-split${defaultOptions.name ? `&name=${defaultOptions.name.trim()}` : ''}${defaultOptions.theme ? `&theme=${defaultOptions.theme}` : ''}&file=${defaultOptions.openFile}${defaultOptions.referer ? `&ref=${defaultOptions.referer}` : ''}&files=${files}` ) : ''; } @@ -254,7 +254,7 @@ const frameworkImportMap: Record Record }; function createImportMap(framework: 'react' | 'preact' | 'angular' | 'lit' | 'vue' | 'vanilla' = 'vanilla') { - const CDN_MODULES_URL = __ELEMENTS_ESM_CDN_BASE_URL__; + const CDN_MODULES_URL = ELEMENTS_ESM_CDN_BASE_URL; const imports: Record = { '@nvidia-elements/core': `${CDN_MODULES_URL}/@nvidia-elements/core@latest`, '@nvidia-elements/core/': `${CDN_MODULES_URL}/@nvidia-elements/core@latest/`, diff --git a/projects/lint/src/eslint/internals/index.test.ts b/projects/lint/src/eslint/internals/index.test.ts index 5d9aaaba3..bdb3ace02 100644 --- a/projects/lint/src/eslint/internals/index.test.ts +++ b/projects/lint/src/eslint/internals/index.test.ts @@ -2,26 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from 'vitest'; -import { lintPlaygroundTemplate } from './index.js'; +import { lintTemplate } from './index.js'; describe('lintPlaygroundTemplate', () => { it('should return empty array for valid HTML code', async () => { const validCode = '
Hello World
'; - const result = await lintPlaygroundTemplate(validCode); + const result = await lintTemplate(validCode, { strict: true }); expect(result).toEqual([]); }); it('should return empty array for valid custom elements', async () => { const validCode = 'Click me'; - const result = await lintPlaygroundTemplate(validCode); + const result = await lintTemplate(validCode, { strict: true }); expect(result).toEqual([]); }); it('should detect restricted attributes on custom elements', async () => { const codeWithRestrictedAttribute = 'Button'; - const result = await lintPlaygroundTemplate(codeWithRestrictedAttribute); + const result = await lintTemplate(codeWithRestrictedAttribute, { strict: true }); expect(result.length).toBeGreaterThan(0); expect( @@ -32,7 +32,7 @@ describe('lintPlaygroundTemplate', () => { it('should map ESLint severity correctly', async () => { const codeWithViolation = 'Button'; - const result = await lintPlaygroundTemplate(codeWithViolation); + const result = await lintTemplate(codeWithViolation, { strict: true }); expect(result.length).toBeGreaterThan(0); const message = result[0]; @@ -41,7 +41,7 @@ describe('lintPlaygroundTemplate', () => { it('should include correct line and column information', async () => { const codeWithViolation = 'Button'; - const result = await lintPlaygroundTemplate(codeWithViolation); + const result = await lintTemplate(codeWithViolation, { strict: true }); expect(result.length).toBeGreaterThan(0); const message = result[0]; @@ -55,7 +55,7 @@ describe('lintPlaygroundTemplate', () => { it('should return proper TemplateLintMessage structure', async () => { const codeWithViolation = 'Button'; - const result = await lintPlaygroundTemplate(codeWithViolation); + const result = await lintTemplate(codeWithViolation, { strict: true }); expect(result.length).toBeGreaterThan(0); const message = result[0]; @@ -80,7 +80,7 @@ describe('lintPlaygroundTemplate', () => { }); it('should return warning for empty string input', async () => { - const result = await lintPlaygroundTemplate(''); + const result = await lintTemplate('', { strict: true }); expect(result).toHaveLength(1); expect(result[0].id).toBe('empty-template'); expect(result[0].severity).toBe('warn'); @@ -88,14 +88,14 @@ describe('lintPlaygroundTemplate', () => { }); it('should return warning for whitespace-only input', async () => { - const result = await lintPlaygroundTemplate(' \n\t '); + const result = await lintTemplate(' \n\t ', { strict: true }); expect(result).toHaveLength(1); expect(result[0].id).toBe('empty-template'); }); it('should handle malformed HTML gracefully', async () => { const malformedCode = '
Unclosed tags'; - const result = await lintPlaygroundTemplate(malformedCode); + const result = await lintTemplate(malformedCode, { strict: true }); expect(Array.isArray(result)).toBe(true); }); @@ -104,7 +104,7 @@ describe('lintPlaygroundTemplate', () => { Button 1 Button 2 `; - const result = await lintPlaygroundTemplate(codeWithMultipleViolations); + const result = await lintTemplate(codeWithMultipleViolations, { strict: true }); expect(result.length).toBeGreaterThan(1); expect(result[0].id).toBe('no-restricted-attributes-with-supported'); @@ -113,7 +113,7 @@ describe('lintPlaygroundTemplate', () => { it('should handle suggestions', async () => { const codeWithSuggestion = 'Button'; - const result = await lintPlaygroundTemplate(codeWithSuggestion); + const result = await lintTemplate(codeWithSuggestion, { strict: true }); expect(result.length).toBeGreaterThan(0); const messageWithSuggestion = result.find(msg => msg.id === 'unexpected-deprecated-global-attribute'); @@ -121,4 +121,18 @@ describe('lintPlaygroundTemplate', () => { expect(messageWithSuggestion.suggestions).toBeDefined(); expect(messageWithSuggestion.suggestions.length).toBeGreaterThan(0); }); + + it('should apply non-strict template rules as warnings', async () => { + const result = await lintTemplate('
', { strict: false }); + const tailwindMessage = result.find(message => message.id === 'no-tailwind-class-with-suggestion'); + + expect(tailwindMessage?.severity).toBe('warn'); + }); + + it('should apply strict template rules as errors', async () => { + const result = await lintTemplate('
', { strict: true }); + const tailwindMessage = result.find(message => message.id === 'no-tailwind-class-with-suggestion'); + + expect(tailwindMessage?.severity).toBe('error'); + }); }); diff --git a/projects/lint/src/eslint/internals/index.ts b/projects/lint/src/eslint/internals/index.ts index 3c6ea4d62..2360e2a46 100644 --- a/projects/lint/src/eslint/internals/index.ts +++ b/projects/lint/src/eslint/internals/index.ts @@ -24,24 +24,26 @@ export interface TemplateLintMessage { endColumn: number; } -export async function lintPlaygroundTemplate(code: string): Promise { - // extra restrictions/rules as agents rarely use these advanced APIs correctly for playground generation out of context of an established project - const rules: Partial = { - '@nvidia-elements/lint/no-unexpected-style-customization': ['error'], - '@nvidia-elements/lint/no-missing-gap-space': ['error'], - '@nvidia-elements/lint/no-missing-slotted-elements': ['error', { 'nve-card': { required: ['nve-card-content'] } }], - '@nvidia-elements/lint/no-unexpected-global-attribute-value': ['error', { distilled: true }], - '@nvidia-elements/lint/no-tailwind-classes': ['error', { strict: true }] - }; - return lintString(code, rules); +interface TemplateLintOptions { + strict: boolean; } -export async function lintTemplate(code: string): Promise { - const rules: Partial = { - '@nvidia-elements/lint/no-unexpected-global-attribute-value': ['error', { distilled: true }], - '@nvidia-elements/lint/no-tailwind-classes': ['warn', { strict: true }], - '@nvidia-elements/lint/no-missing-gap-space': ['warn'] - }; +const strictTemplateRules: Partial = { + '@nvidia-elements/lint/no-unexpected-global-attribute-value': ['error', { distilled: true }], + '@nvidia-elements/lint/no-tailwind-classes': ['error', { strict: true }], + '@nvidia-elements/lint/no-unexpected-style-customization': ['error'], + '@nvidia-elements/lint/no-missing-gap-space': ['error'], + '@nvidia-elements/lint/no-missing-slotted-elements': ['error', { 'nve-card': { required: ['nve-card-content'] } }] +}; + +const templateRules: Partial = { + '@nvidia-elements/lint/no-unexpected-global-attribute-value': ['error', { distilled: true }], + '@nvidia-elements/lint/no-tailwind-classes': ['warn', { strict: true }], + '@nvidia-elements/lint/no-missing-gap-space': ['warn'] +}; + +export async function lintTemplate(code: string, { strict }: TemplateLintOptions): Promise { + const rules = strict ? strictTemplateRules : templateRules; return lintString(code, rules); } diff --git a/release.config.js b/release.config.js index 17171b130..fd5b2f7b8 100644 --- a/release.config.js +++ b/release.config.js @@ -94,18 +94,7 @@ export default { '@semantic-release/github', { successComment: - '🎉 This issue has been resolved in version ${nextRelease.version} 🎉\n\n[Changelog](https://NVIDIA.github.io/elements/docs/changelog/)', - ...(scope === 'cli' - ? { - assets: [ - { path: 'dist/nve-macos-arm64', name: 'nve-macos-arm64', label: 'CLI (macOS ARM64)' }, - { path: 'dist/nve-macos-x64', name: 'nve-macos-x64', label: 'CLI (macOS x64)' }, - { path: 'dist/nve-linux-x64', name: 'nve-linux-x64', label: 'CLI (Linux x64)' }, - { path: 'dist/nve-linux-arm64', name: 'nve-linux-arm64', label: 'CLI (Linux ARM64)' }, - { path: 'dist/nve-windows-x64.exe', name: 'nve-windows-x64.exe', label: 'CLI (Windows x64)' } - ] - } - : {}) + '🎉 This issue has been resolved in version ${nextRelease.version} 🎉\n\n[Changelog](https://NVIDIA.github.io/elements/docs/changelog/)' } ] ]