From 7b793b305d39396a76ac761a3f691271f5da0316 Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Fri, 2 Oct 2026 02:09:10 -0400 Subject: [PATCH 1/2] feat: Arduino Agent colors (Arduino's teal moved to the icon's indigo) The new name and icon set Arduino Agent apart, but inside it still wore Arduino's teal everywhere. Its light and dark themes now use the indigo of the app icon. - agent-palette.ts: pure OKLCH color math. Teal accents (hue 160-240, chroma >= 0.05) take the icon's hue at their original lightness, with a bit more chroma; teal-tinted neutrals keep lightness and their tiny chroma but lean indigo; everything else is untouched. Same lightness keeps the contrast Arduino tuned. - agent-theme.ts: registers the converted themes under Arduino's ids (arduino-theme / arduino-theme-dark), which the settings dialog lists as the built-in Light/Dark and users' saved preference keeps pointing at. Computed from Arduino's JSON at registration, so upstream theme updates carry over. - Registers arduino.branding.primary/secondary. Arduino's themes set them and its CSS uses var(--theia-arduino-branding-primary) (boards dialog, library/board lists, serial monitor scrollbar, progress bar), but upstream never registered them, so the variable was undefined and those accents never rendered. - Overrides the few rules in arduino-ide-extension's CSS that hardcode teal (board/port status bar items, dialog border, list separators, focus shadow) in terms of the branding color. - Unit tests for the palette (yarn test:palette). Co-Authored-By: Claude Opus 5.5 --- arduino-mcp-extension/package.json | 1 + .../src/browser/agent-palette.ts | 95 +++++++++++++++ .../src/browser/agent-theme.ts | 110 ++++++++++++++++++ .../browser/arduino-mcp-frontend-module.ts | 9 ++ .../test/theme/agent-palette.test.js | 71 +++++++++++ 5 files changed, 286 insertions(+) create mode 100644 arduino-mcp-extension/src/browser/agent-palette.ts create mode 100644 arduino-mcp-extension/src/browser/agent-theme.ts create mode 100644 arduino-mcp-extension/test/theme/agent-palette.test.js 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`); + } +}); From 5b02961c7a04d8e0b998ee2767b303fca9ff0d7a Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Fri, 2 Oct 2026 02:09:34 -0400 Subject: [PATCH 2/2] docs: note where Arduino Agent's colors come from Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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