diff --git a/CLAUDE.md b/CLAUDE.md index 1ccf8131a92..aec859c784e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,10 @@ with upstream); `arduino-mcp-extension/` is ours; `electron-app/` packages both. Fork identity (About dialog, Arduino's IDE updater switched off) is overridden from `arduino-mcp-extension/src/browser/agent-branding.ts` via rebinds, not by -editing vendored files. `arduino-ide-extension` emits no `.d.ts`, so its +editing vendored files. Colors likewise: `agent-theme.ts` registers Arduino's +theme JSONs (still under Arduino's theme ids) after `agent-palette.ts` moves +their teal to indigo, so syncing new upstream theme JSONs needs no manual +recoloring; check `yarn test:palette` still passes. `arduino-ide-extension` emits no `.d.ts`, so its classes are `any` in our extension: no `override`, no inherited fields. ## Build diff --git a/arduino-mcp-extension/package.json b/arduino-mcp-extension/package.json index 6db2e6c3b6a..5e1ba8f4db6 100644 --- a/arduino-mcp-extension/package.json +++ b/arduino-mcp-extension/package.json @@ -35,6 +35,7 @@ "clean": "rimraf lib", "build": "tsc", "test:bridge": "node --test test/bridge/bridge.test.js", + "test:palette": "node --test test/theme/agent-palette.test.js", "build:dev": "yarn build", "watch": "tsc -w" } diff --git a/arduino-mcp-extension/src/browser/agent-palette.ts b/arduino-mcp-extension/src/browser/agent-palette.ts new file mode 100644 index 00000000000..dd8cf9856a7 --- /dev/null +++ b/arduino-mcp-extension/src/browser/agent-palette.ts @@ -0,0 +1,95 @@ +/** + * The color math behind agent-theme.ts, free of Theia imports so it can be + * unit-tested: teal (OKLCH hue 160-240) moves to the icon's indigo at the + * same lightness; accents gain some chroma, tinted neutrals keep theirs. + */ + +const TEAL_HUES: [number, number] = [160, 240]; +const ACCENT_HUE = 277; // the icon's #4F46E5 +const NEUTRAL_HUE = 275; +const ACCENT_MIN_CHROMA = 0.05; +const ACCENT_CHROMA_GAIN = 1.6; +const MAX_CHROMA = 0.2; + +/** A deep copy of a VS Code color theme with every color passed through agentColor(). */ +export function toAgentPalette(theme: T): T { + const visit = (value: unknown): unknown => { + if (typeof value === 'string') { + return /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value) ? agentColor(value) : value; + } + if (Array.isArray(value)) { + return value.map(visit); + } + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, visit(v)])); + } + return value; + }; + return visit(theme) as T; +} + +/** Moves a teal color to indigo (see the file header); other colors pass through. */ +export function agentColor(hex: string): string { + const alpha = hex.slice(7); + const [L, C, H] = rgbToOklch(hexToRgb(hex)); + if (C < 0.003 || H < TEAL_HUES[0] || H > TEAL_HUES[1]) { + return hex; + } + const accent = C >= ACCENT_MIN_CHROMA; + const hue = accent ? ACCENT_HUE : NEUTRAL_HUE; + let chroma = accent ? Math.min(C * ACCENT_CHROMA_GAIN, MAX_CHROMA) : C; + // Reduce chroma until the color fits in sRGB. + let rgb = oklchToRgb(L, chroma, hue); + while (!inGamut(rgb) && chroma > 0) { + chroma = Math.max(0, chroma - 0.005); + rgb = oklchToRgb(L, chroma, hue); + } + return rgbToHex(rgb) + alpha; +} + +type RGB = [number, number, number]; + +function hexToRgb(hex: string): RGB { + return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as RGB; +} + +function rgbToHex(rgb: RGB): string { + return ( + '#' + + rgb + .map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, '0')) + .join('') + ); +} + +function inGamut(rgb: RGB): boolean { + return rgb.every((c) => c >= -0.0005 && c <= 1.0005); +} + +const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const fromLinear = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.sign(c) * Math.abs(c) ** (1 / 2.4) - 0.055); + +function rgbToOklch(rgb: RGB): [number, number, number] { + const [r, g, b] = rgb.map(toLinear); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + const H = ((Math.atan2(bb, a) * 180) / Math.PI + 360) % 360; + return [L, Math.hypot(a, bb), H]; +} + +function oklchToRgb(L: number, C: number, H: number): RGB { + const a = C * Math.cos((H * Math.PI) / 180); + const b = C * Math.sin((H * Math.PI) / 180); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ].map(fromLinear) as RGB; +} diff --git a/arduino-mcp-extension/src/browser/agent-theme.ts b/arduino-mcp-extension/src/browser/agent-theme.ts new file mode 100644 index 00000000000..7687ac71c25 --- /dev/null +++ b/arduino-mcp-extension/src/browser/agent-theme.ts @@ -0,0 +1,110 @@ +/** + * Arduino Agent's colors: Arduino's light and dark themes with their teal moved + * to the indigo of the app icon, so the IDE looks like its own app. + * + * The themes keep Arduino's ids (`arduino-theme`, `arduino-theme-dark`): the + * vendored settings dialog lists exactly those as the built-in Light/Dark, and + * users' saved `workbench.colorTheme` keeps working. Only the content changes, + * computed from Arduino's theme JSON at registration so upstream theme updates + * carry over: + * + * - accents (teal hues, chroma >= 0.05 in OKLCH) take the icon's indigo hue, + * with somewhat more chroma, at their original lightness; + * - neutrals with a slight teal tint keep their lightness and tiny chroma but + * lean toward indigo instead of teal; + * - everything else (whites, greys, the yellow/red highlights) is unchanged. + * + * Keeping lightness keeps the contrast Arduino tuned. A few rules in + * arduino-ide-extension's CSS hardcode teal; AGENT_THEME_CSS overrides them in + * terms of the theme's branding color. + */ + +import { injectable } from '@theia/core/shared/inversify'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution'; +import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution'; +import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; +import { MonacoThemingService } from 'arduino-ide-extension/lib/browser/theia/monaco/monaco-theming-service'; +import { ArduinoThemes } from 'arduino-ide-extension/lib/browser/theia/core/theming'; +import { toAgentPalette } from './agent-palette'; + +/** The inherited Theia method used below (the vendored base class is untyped here). */ +interface ParsedThemeRegistry { + registerParsedTheme(theme: { id: string; label: string; uiTheme: 'vs' | 'vs-dark'; json: unknown }): unknown; +} + +@injectable() +export class AgentMonacoThemingService extends MonacoThemingService { + /** Replaces arduino-ide-extension's registration (called from its restore()). */ + registerArduinoThemes(): void { + const { light, dark } = ArduinoThemes; + const registry = this as unknown as ParsedThemeRegistry; + registry.registerParsedTheme({ + id: light.id, + label: light.label, + uiTheme: 'vs', + json: toAgentPalette(require('arduino-ide-extension/src/browser/data/default.color-theme.json')), + }); + registry.registerParsedTheme({ + id: dark.id, + label: dark.label, + uiTheme: 'vs-dark', + json: toAgentPalette(require('arduino-ide-extension/src/browser/data/dark.color-theme.json')), + }); + } +} + +/** + * Registers `arduino.branding.primary`/`secondary`. Arduino's themes set them and + * its CSS uses `var(--theia-arduino-branding-primary)` (boards dialog, library and + * board lists, serial monitor scrollbar, progress bar), but upstream never + * registers them, so the variable was undefined and those accents never showed. + * Also installs the CSS overrides for the teal hardcoded in arduino-ide-extension's + * stylesheets. + */ +@injectable() +export class AgentThemeStyles implements FrontendApplicationContribution, ColorContribution { + registerColors(colors: ColorRegistry): void { + colors.register( + { + id: 'arduino.branding.primary', + defaults: { light: '#5d64c6', dark: '#757ff4', hcLight: 'focusBorder', hcDark: 'focusBorder' }, + description: 'Arduino Agent accent color (links, highlights, the serial monitor scrollbar).', + }, + { + id: 'arduino.branding.secondary', + defaults: { light: '#bfc3d2', dark: '#bfc3d2', hcLight: 'foreground', hcDark: 'foreground' }, + description: 'Arduino Agent secondary branding color.', + } + ); + } + + initialize(): void { + const style = document.createElement('style'); + style.id = 'arduino-agent-theme'; + style.textContent = AGENT_THEME_CSS; + document.head.appendChild(style); + } +} + +const brand = (percent: number) => + `color-mix(in srgb, var(--theia-arduino-branding-primary) ${percent}%, transparent)`; + +const AGENT_THEME_CSS = ` +:root { + --arduino-shadow-focus: 0 0 0 3px ${brand(25)}; +} +#theia-statusBar .area .element.arduino-selected-port, +#theia-statusBar .area .element.arduino-selected-board { + background: ${brand(10)}; +} +#theia-statusBar .area .element.arduino-selected-port:hover, +#theia-statusBar .area .element.arduino-selected-board:hover { + background: ${brand(20)}; +} +.p-Widget.dialogOverlay .dialogBlock { + border-color: ${brand(10)}; +} +.component-list-item { + border-bottom-color: ${brand(8)}; +} +`; diff --git a/arduino-mcp-extension/src/browser/arduino-mcp-frontend-module.ts b/arduino-mcp-extension/src/browser/arduino-mcp-frontend-module.ts index a6e2aff753b..b50bdb97675 100644 --- a/arduino-mcp-extension/src/browser/arduino-mcp-frontend-module.ts +++ b/arduino-mcp-extension/src/browser/arduino-mcp-frontend-module.ts @@ -10,15 +10,18 @@ import { ContainerModule } from '@theia/core/shared/inversify'; import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/ws-connection-provider'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution'; +import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution'; import { CommandContribution } from '@theia/core/lib/common/command'; import { MenuContribution } from '@theia/core/lib/common/menu'; import { About } from 'arduino-ide-extension/lib/browser/contributions/about'; import { CheckForIDEUpdates } from 'arduino-ide-extension/lib/browser/contributions/check-for-ide-updates'; +import { MonacoThemingService } from 'arduino-ide-extension/lib/browser/theia/monaco/monaco-theming-service'; import { bindMCPPreferences, MCPPreferences } from './mcp-preferences'; import { MCPService, MCPServicePath } from '../common/mcp-service'; import { MCPFrontendContribution } from './mcp-frontend-contribution'; import { AgentAbout, DisabledIDEUpdateCheck, RemoveIDEUpdaterCommand } from './agent-branding'; import { ConnectAgentContribution } from './connect-agent'; +import { AgentMonacoThemingService, AgentThemeStyles } from './agent-theme'; export default new ContainerModule((bind, unbind, isBound, rebind) => { // Bind MCP preferences to add settings to the IDE preferences panel @@ -31,6 +34,12 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(CommandContribution).toService(RemoveIDEUpdaterCommand); bind(MenuContribution).toService(RemoveIDEUpdaterCommand); + // Arduino Agent colors: Arduino's themes recolored teal -> indigo (agent-theme.ts) + rebind(MonacoThemingService).to(AgentMonacoThemingService).inSingletonScope(); + bind(AgentThemeStyles).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(AgentThemeStyles); + bind(ColorContribution).toService(AgentThemeStyles); + // Bind the MCP service proxy to communicate with backend bind(MCPService).toDynamicValue(ctx => { const connection = ctx.container.get(WebSocketConnectionProvider); diff --git a/arduino-mcp-extension/test/theme/agent-palette.test.js b/arduino-mcp-extension/test/theme/agent-palette.test.js new file mode 100644 index 00000000000..9141ff43834 --- /dev/null +++ b/arduino-mcp-extension/test/theme/agent-palette.test.js @@ -0,0 +1,71 @@ +/** + * Tests for the teal -> indigo recoloring of Arduino's themes (src/browser/agent-palette.ts). + * + * yarn test:palette (from arduino-mcp-extension, after `yarn build`) + */ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { agentColor, toAgentPalette } = require('../../lib/browser/agent-palette'); + +const THEMES = path.join(__dirname, '..', '..', '..', 'arduino-ide-extension', 'src', 'browser', 'data'); + +// Minimal OKLCH lightness/hue for assertions (same math as the module). +function oklch(hex) { + const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + const [r, g, b] = [1, 3, 5].map((i) => lin(parseInt(hex.slice(i, i + 2), 16) / 255)); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + return { L, C: Math.hypot(A, B), H: ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360 }; +} + +test("Arduino's teal accents move to the icon's indigo at the same lightness", () => { + for (const teal of ['#008184', '#0ca1a6', '#005c5f', '#7fcbcd', '#00979d']) { + const before = oklch(teal); + const after = oklch(agentColor(teal)); + assert.ok(Math.abs(after.H - 277) < 3, `${teal} -> hue ${after.H.toFixed(0)}`); + assert.ok(Math.abs(after.L - before.L) < 0.01, `${teal} lightness ${before.L.toFixed(3)} -> ${after.L.toFixed(3)}`); + assert.ok(after.C >= before.C, `${teal} keeps at least its chroma`); + } +}); + +test('teal-tinted neutrals stay neutral', () => { + for (const grey of ['#dae3e3', '#1f272a', '#4e5b61', '#171e21']) { + const before = oklch(grey); + const after = oklch(agentColor(grey)); + assert.ok(Math.abs(after.L - before.L) < 0.01, `${grey} lightness kept`); + assert.ok(after.C < 0.03, `${grey} -> chroma ${after.C.toFixed(3)} stays low`); + } +}); + +test('non-teal colors and alpha suffixes are untouched', () => { + for (const color of ['#ffffff', '#000000', '#bfbfbf', '#ff0000', '#f1c40f', '#df7365', '#d35400']) { + assert.strictEqual(agentColor(color), color); + } + assert.match(agentColor('#dae3e366'), /^#[0-9a-f]{6}66$/); +}); + +test("both of Arduino's themes convert without leftover teal accents, and the input is not mutated", () => { + for (const file of ['default.color-theme.json', 'dark.color-theme.json']) { + const original = JSON.parse(fs.readFileSync(path.join(THEMES, file), 'utf8')); + const snapshot = JSON.stringify(original); + const agent = toAgentPalette(original); + assert.strictEqual(JSON.stringify(original), snapshot, `${file} input mutated`); + const colors = [ + ...Object.values(agent.colors), + ...(agent.tokenColors || []).flatMap((t) => Object.values(t.settings || {})), + ].filter((v) => /^#[0-9a-f]{6}/i.test(v)); + const teal = colors.filter((c) => { + const { C, H } = oklch(c.slice(0, 7)); + return C >= 0.05 && H >= 160 && H <= 240; + }); + assert.deepStrictEqual(teal, [], `${file} still has teal accents`); + } +});