diff --git a/desktop/README.md b/desktop/README.md index a24bdc9b..af49c591 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -223,6 +223,20 @@ deepcode provider test personal-openrouter --model deepcode -c personal-openrouter -m --effort auto ``` +### Appearance and imported themes + +**Settings → Appearance** controls the machine-local theme, conversation width, +and typography. **Import VS Code theme** accepts one local `.json` or `.jsonc` +color-theme file, validates every value in its `colors` object, and maps the +supported workbench colors onto DeepCode's complete palette. Unmapped tokens +come from the inferred light or dark base, so an imported theme never leaves a +component on an unrelated fallback palette. + +Only the normalized palette and display name are persisted. DeepCode does not +retain the source path or raw file, execute theme content, discover extension +packages, import syntax `tokenColors`, or follow `include` chains. Invalid +colors are reported in the Appearance panel and leave the current theme intact. + ### Run a durable Goal Use **Set a Goal** above the Session composer to define one natural-language diff --git a/desktop/package-lock.json b/desktop/package-lock.json index e9403f98..ba66d474 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -16,6 +16,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "i18next": "^26.3.6", + "jsonc-parser": "^3.3.1", "lucide-react": "^0.468.0", "monaco-editor": "0.53.0", "prism-react-renderer": "^2.4.1", @@ -3224,6 +3225,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/desktop/package.json b/desktop/package.json index fdc8d8a0..2de43fef 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -29,6 +29,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "i18next": "^26.3.6", + "jsonc-parser": "^3.3.1", "lucide-react": "^0.468.0", "monaco-editor": "0.53.0", "prism-react-renderer": "^2.4.1", diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 58575aa0..02ab890a 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -1932,6 +1932,54 @@ describe("desktop command center", () => { expect(document.documentElement.getAttribute("data-theme")).toBeNull(); }); + it("imports one VS Code theme and reports invalid colors", async () => { + const runtime = new TestRuntime([project], [thread], []); + render(); + + await screen.findByRole("heading", { name: "Recovered task" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + const dialog = await screen.findByRole("dialog", { name: "Settings" }); + const input = within(dialog).getByLabelText( + "Import VS Code theme", + ) as HTMLInputElement; + const valid = new File(["theme"], "ocean-color-theme.jsonc", { + type: "application/json", + }); + Object.defineProperty(valid, "text", { + value: () => + Promise.resolve(`{ + // local JSONC only + "name": "Ocean", + "colors": { + "editor.background": "#102030", + "editor.foreground": "#f0f4f8", + }, + }`), + }); + + fireEvent.change(input, { target: { files: [valid] } }); + + await waitFor(() => + expect(document.documentElement.getAttribute("data-theme")).toBe( + "imported", + ), + ); + expect( + document.documentElement.style.getPropertyValue("--surface-canvas"), + ).toBe("#102030"); + expect(within(dialog).getByRole("option", { name: /Ocean/ })).toBeTruthy(); + + const invalid = new File(["theme"], "invalid.json"); + Object.defineProperty(invalid, "text", { + value: () => + Promise.resolve('{"colors":{"editor.background":"not-a-color"}}'), + }); + fireEvent.change(input, { target: { files: [invalid] } }); + expect((await within(dialog).findByRole("alert")).textContent).toContain( + "Invalid color for editor.background", + ); + }); + it("lets plain Enter queue while busy when the preference says queue", async () => { localStorage.setItem( "deepcode.desktop.composer.v1", diff --git a/desktop/src/app/appearance.test.ts b/desktop/src/app/appearance.test.ts index 47d281e1..7cdb6aaf 100644 --- a/desktop/src/app/appearance.test.ts +++ b/desktop/src/app/appearance.test.ts @@ -8,6 +8,7 @@ import { sanitizeAppearance, writeAppearance, } from "./appearance"; +import { parseVsCodeTheme } from "./importedTheme"; function root(): HTMLElement { return document.documentElement; @@ -39,6 +40,10 @@ describe("sanitizeAppearance", () => { expect(state).toEqual(APPEARANCE_DEFAULTS); }); + it("does not select an imported theme without a valid stored palette", () => { + expect(sanitizeAppearance({ theme: "imported" }).theme).toBe("system"); + }); + it("is total over the settings table", () => { // A row added without a matching default would silently produce // `undefined` here rather than failing at the point of the mistake. @@ -57,6 +62,22 @@ describe("persistence", () => { expect(restored.theme).toBe("dark"); }); + it("round-trips a normalized imported palette", () => { + const importedTheme = parseVsCodeTheme( + '{"name":"Stored","colors":{"editor.background":"#123456"}}', + "stored.json", + ); + writeAppearance({ + ...APPEARANCE_DEFAULTS, + theme: "imported", + importedTheme, + }); + const restored = readAppearance(); + expect(restored.theme).toBe("imported"); + expect(restored.importedTheme?.name).toBe("Stored"); + expect(restored.importedTheme?.tokens["--surface-canvas"]).toBe("#123456"); + }); + it("falls back to defaults when storage holds garbage", () => { localStorage.setItem("deepcode.desktop.appearance.v1", "{not json"); expect(readAppearance()).toEqual(APPEARANCE_DEFAULTS); @@ -90,6 +111,22 @@ describe("applyAppearance", () => { expect(root().hasAttribute("data-theme")).toBe(false); }); + it("applies an imported palette through the theme attribute", () => { + const importedTheme = parseVsCodeTheme( + '{"colors":{"editor.background":"#123456"}}', + "imported.json", + ); + applyAppearance( + { ...APPEARANCE_DEFAULTS, theme: "imported", importedTheme }, + root(), + ); + expect(root().getAttribute("data-theme")).toBe("imported"); + expect(root().style.getPropertyValue("--surface-canvas")).toBe("#123456"); + + applyAppearance(APPEARANCE_DEFAULTS, root()); + expect(root().style.getPropertyValue("--surface-canvas")).toBe(""); + }); + it("appends preferred fonts as a prefix of the built-in stack", () => { applyAppearance( { ...APPEARANCE_DEFAULTS, fontFamily: "Sarasa Mono SC, Inter" }, diff --git a/desktop/src/app/appearance.ts b/desktop/src/app/appearance.ts index 2707eaaa..64eb6cd7 100644 --- a/desktop/src/app/appearance.ts +++ b/desktop/src/app/appearance.ts @@ -12,6 +12,12 @@ * which keeps components free of appearance conditionals. */ +import { + applyImportedTheme, + sanitizeImportedTheme, + type ImportedTheme, +} from "./importedTheme"; + const STORAGE_KEY = "deepcode.desktop.appearance.v1"; /** @@ -30,7 +36,8 @@ export type ThemePreference = | "midnight" | "claude" | "claude-dark" - | "contrast"; + | "contrast" + | "imported"; export const THEME_PREFERENCES: readonly ThemePreference[] = [ "system", @@ -41,6 +48,7 @@ export const THEME_PREFERENCES: readonly ThemePreference[] = [ "claude", "claude-dark", "contrast", + "imported", ]; export interface AppearanceState { @@ -55,6 +63,8 @@ export interface AppearanceState { * fixed list would be both wrong and stale. Empty means "use the default". */ fontFamily: string; + /** Complete, normalized palette imported from one local VS Code theme. */ + importedTheme: ImportedTheme | null; } export const APPEARANCE_DEFAULTS: AppearanceState = { @@ -62,6 +72,7 @@ export const APPEARANCE_DEFAULTS: AppearanceState = { theme: "system", fontSize: 14, fontFamily: "", + importedTheme: null, }; /** @@ -153,13 +164,17 @@ export const APPEARANCE_SETTINGS = [ export function sanitizeAppearance(value: unknown): AppearanceState { const raw = typeof value === "object" && value !== null ? value : {}; const source = raw as Record; - return APPEARANCE_SETTINGS.reduce( + const importedTheme = sanitizeImportedTheme(source.importedTheme); + const state = APPEARANCE_SETTINGS.reduce( (state, setting) => ({ ...state, [setting.key]: setting.sanitize(source[setting.key]), }), - { ...APPEARANCE_DEFAULTS }, + { ...APPEARANCE_DEFAULTS, importedTheme }, ); + return state.theme === "imported" && !importedTheme + ? { ...state, theme: "system" } + : state; } export function readAppearance(): AppearanceState { @@ -195,6 +210,8 @@ export function applyAppearance(state: AppearanceState, root: HTMLElement): void } } + applyImportedTheme(state.theme === "imported" ? state.importedTheme : null, root); + if (state.theme === "system") { root.removeAttribute("data-theme"); } else { diff --git a/desktop/src/app/i18n.ts b/desktop/src/app/i18n.ts index a3e6efe0..7a0a3f42 100644 --- a/desktop/src/app/i18n.ts +++ b/desktop/src/app/i18n.ts @@ -58,6 +58,11 @@ const ZH_CN: Record = { "settings.appearance.claude": "Claude · 象牙白与陶土色", "settings.appearance.claudeDark": "Claude 深色 · 石板灰与陶土色", "settings.appearance.contrast": "高对比度 · AAA", + "settings.appearance.imported": "导入的主题", + "settings.appearance.importTheme": "导入 VS Code 主题", + "settings.appearance.importedReady": "已导入 {{name}} · {{base}} 基础主题", + "settings.appearance.importHint": + "读取一个本地 JSON/JSONC 颜色主题文件;暂不导入主题 include 链和语法配色。", "settings.appearance.fontPlaceholder": "例如:更纱黑体 SC、Inter", "settings.appearance.addInstalledFont": "添加已安装字体…", "settings.appearance.fontGroup.interface": "界面字体", diff --git a/desktop/src/app/importedTheme.test.ts b/desktop/src/app/importedTheme.test.ts new file mode 100644 index 00000000..9f24601e --- /dev/null +++ b/desktop/src/app/importedTheme.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + IMPORTED_THEME_TOKEN_NAMES, + ThemeImportError, + applyImportedTheme, + parseVsCodeTheme, + sanitizeImportedTheme, +} from "./importedTheme"; + +const SOURCE = `{ + // VS Code themes commonly use JSONC. + "name": "Night Test", + "colors": { + "editor.background": "#112233", + "editor.foreground": "#ddeeff", + "sideBar.background": "#223344", + "focusBorder": "#abc", + "testing.iconPassed": "#22aa66", + "widget.shadow": "#000000", + }, +}`; + +beforeEach(() => { + document.documentElement.removeAttribute("style"); +}); + +describe("parseVsCodeTheme", () => { + it("accepts JSONC, infers a dark base, and fills every token", () => { + const theme = parseVsCodeTheme(SOURCE, "night-color-theme.jsonc"); + + expect(theme.name).toBe("Night Test"); + expect(theme.base).toBe("dark"); + expect(theme.tokens["--surface-canvas"]).toBe("#112233"); + expect(theme.tokens["--text-primary"]).toBe("#ddeeff"); + expect(theme.tokens["--surface-sidebar"]).toBe("#223344"); + expect(theme.tokens["--signal"]).toBe("#aabbcc"); + expect(theme.tokens["--signal-soft"]).toBe("#aabbcc29"); + expect(theme.tokens["--shadow-float"]).toContain("0 18px 48px #00000052"); + expect(Object.keys(theme.tokens).sort()).toEqual( + [...IMPORTED_THEME_TOKEN_NAMES].sort(), + ); + }); + + it("rejects invalid colors and unsupported include chains visibly", () => { + expect(() => + parseVsCodeTheme('{"colors":{"editor.background":"red"}}', "bad.json"), + ).toThrow(ThemeImportError); + expect(() => + parseVsCodeTheme( + '{"include":"./base.json","colors":{"editor.background":"#fff"}}', + "included.json", + ), + ).toThrow(/include chains are not supported/i); + }); + + it("derives a useful name from the file without persisting its path", () => { + const theme = parseVsCodeTheme( + '{"colors":{"editor.background":"#ffffff"}}', + "Quiet-Light-color-theme.json", + ); + expect(theme.name).toBe("Quiet-Light"); + expect(JSON.stringify(theme)).not.toContain("color-theme.json"); + }); +}); + +describe("stored and applied themes", () => { + it("sanitizes known tokens and restores a complete base", () => { + const theme = sanitizeImportedTheme({ + name: "Stored", + base: "light", + tokens: { "--surface-canvas": "#abcdef" }, + }); + expect(theme?.tokens["--surface-canvas"]).toBe("#abcdef"); + expect(Object.keys(theme?.tokens ?? {})).toHaveLength( + IMPORTED_THEME_TOKEN_NAMES.length, + ); + }); + + it("writes every imported token and clears them together", () => { + const theme = parseVsCodeTheme(SOURCE, "night.jsonc"); + const root = document.documentElement; + + applyImportedTheme(theme, root); + expect(root.style.getPropertyValue("--imported-color-scheme")).toBe("dark"); + expect(root.style.getPropertyValue("--surface-canvas")).toBe("#112233"); + + applyImportedTheme(null, root); + expect(root.style.getPropertyValue("--imported-color-scheme")).toBe(""); + expect(root.style.getPropertyValue("--surface-canvas")).toBe(""); + }); +}); diff --git a/desktop/src/app/importedTheme.ts b/desktop/src/app/importedTheme.ts new file mode 100644 index 00000000..11dc97be --- /dev/null +++ b/desktop/src/app/importedTheme.ts @@ -0,0 +1,356 @@ +import { parse, printParseErrorCode, type ParseError } from "jsonc-parser"; + +export const IMPORTED_THEME_TOKEN_NAMES = [ + "--surface-shell", + "--surface-canvas", + "--surface-sidebar", + "--surface-sidebar-strong", + "--surface-raised", + "--surface-overlay", + "--surface-hover", + "--surface-selected", + "--surface-user", + "--surface-code", + "--surface-inset", + "--text-primary", + "--text-secondary", + "--text-tertiary", + "--text-inverse", + "--text-on-accent", + "--border-subtle", + "--border-strong", + "--border-emphasis", + "--signal", + "--signal-strong", + "--signal-soft", + "--signal-faint", + "--success", + "--success-soft", + "--attention", + "--attention-soft", + "--danger", + "--danger-soft", + "--shadow-soft", + "--shadow-float", + "--shadow-menu", +] as const; + +export type ImportedThemeToken = (typeof IMPORTED_THEME_TOKEN_NAMES)[number]; +export type ImportedThemeBase = "light" | "dark"; +export type ImportedThemeTokens = Record; + +export interface ImportedTheme { + name: string; + base: ImportedThemeBase; + tokens: ImportedThemeTokens; +} + +export class ThemeImportError extends Error { + constructor(message: string) { + super(message); + this.name = "ThemeImportError"; + } +} + +export const LIGHT_IMPORTED_THEME_BASE: ImportedThemeTokens = { + "--surface-shell": "#e4e9e5", + "--surface-canvas": "#f7f8f7", + "--surface-sidebar": "#eef1ef", + "--surface-sidebar-strong": "#e7ebe8", + "--surface-raised": "#ffffff", + "--surface-overlay": "rgb(255 255 255 / 88%)", + "--surface-hover": "#e5e9e6", + "--surface-selected": "#dde3e0", + "--surface-user": "#edf0ef", + "--surface-code": "#f0f2f1", + "--surface-inset": "#e8ece9", + "--text-primary": "#202321", + "--text-secondary": "#474b48", + "--text-tertiary": "#626964", + "--text-inverse": "#f8faf9", + "--text-on-accent": "#ffffff", + "--border-subtle": "#dfe4e1", + "--border-strong": "#cfd6d2", + "--border-emphasis": "#aab4ae", + "--signal": "#4d5bd5", + "--signal-strong": "#4150bd", + "--signal-soft": "rgb(77 91 213 / 13%)", + "--signal-faint": "rgb(77 91 213 / 7%)", + "--success": "#3a725e", + "--success-soft": "#e7f1ed", + "--attention": "#935b2f", + "--attention-soft": "#f8eee6", + "--danger": "#ab4850", + "--danger-soft": "#f8e9eb", + "--shadow-soft": "0 1px 2px rgb(27 34 30 / 4%)", + "--shadow-float": + "0 18px 48px rgb(27 34 30 / 10%), 0 3px 10px rgb(27 34 30 / 5%)", + "--shadow-menu": + "0 18px 42px rgb(27 34 30 / 14%), 0 3px 10px rgb(27 34 30 / 7%)", +}; + +export const DARK_IMPORTED_THEME_BASE: ImportedThemeTokens = { + "--surface-shell": "#101211", + "--surface-canvas": "#181b19", + "--surface-sidebar": "#151816", + "--surface-sidebar-strong": "#1b1f1c", + "--surface-raised": "#222624", + "--surface-overlay": "rgb(29 33 30 / 88%)", + "--surface-hover": "#292e2b", + "--surface-selected": "#303632", + "--surface-user": "#282d2a", + "--surface-code": "#151816", + "--surface-inset": "#121513", + "--text-primary": "#f1f4f2", + "--text-secondary": "#b3bab5", + "--text-tertiary": "#919892", + "--text-inverse": "#151816", + "--text-on-accent": "#ffffff", + "--border-subtle": "#2b302d", + "--border-strong": "#3a413d", + "--border-emphasis": "#59625d", + "--signal": "#929cff", + "--signal-strong": "#aab2ff", + "--signal-soft": "rgb(146 156 255 / 16%)", + "--signal-faint": "rgb(146 156 255 / 8%)", + "--success": "#6abb9c", + "--success-soft": "rgb(63 125 103 / 17%)", + "--attention": "#d79058", + "--attention-soft": "rgb(178 100 43 / 14%)", + "--danger": "#e4777e", + "--danger-soft": "rgb(179 64 73 / 14%)", + "--shadow-soft": "0 1px 2px rgb(0 0 0 / 16%)", + "--shadow-float": + "0 20px 52px rgb(0 0 0 / 32%), 0 3px 10px rgb(0 0 0 / 22%)", + "--shadow-menu": + "0 20px 46px rgb(0 0 0 / 42%), 0 3px 10px rgb(0 0 0 / 26%)", +}; + +const DIRECT_MAPPINGS: Partial< + Record +> = { + "--surface-shell": ["activityBar.background", "sideBar.background"], + "--surface-canvas": ["editor.background"], + "--surface-sidebar": ["sideBar.background"], + "--surface-sidebar-strong": [ + "sideBarSectionHeader.background", + "activityBar.background", + ], + "--surface-raised": ["editorWidget.background", "panel.background"], + "--surface-overlay": ["editorWidget.background", "quickInput.background"], + "--surface-hover": ["list.hoverBackground"], + "--surface-selected": [ + "list.activeSelectionBackground", + "list.inactiveSelectionBackground", + ], + "--surface-user": [ + "list.inactiveSelectionBackground", + "editor.selectionBackground", + ], + "--surface-code": ["textCodeBlock.background", "editor.background"], + "--surface-inset": ["input.background"], + "--text-primary": ["foreground", "editor.foreground"], + "--text-secondary": ["descriptionForeground"], + "--text-tertiary": ["disabledForeground"], + "--text-inverse": ["button.foreground", "activityBar.foreground"], + "--text-on-accent": ["button.foreground"], + "--border-subtle": ["widget.border", "panel.border"], + "--border-strong": ["panel.border", "contrastBorder"], + "--border-emphasis": ["contrastBorder", "focusBorder"], + "--signal": ["focusBorder", "button.background"], + "--signal-strong": ["button.hoverBackground", "focusBorder"], + "--success": [ + "testing.iconPassed", + "gitDecoration.addedResourceForeground", + ], + "--attention": ["editorWarning.foreground", "list.warningForeground"], + "--danger": ["errorForeground", "editorError.foreground"], +}; + +const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +export function parseVsCodeTheme( + source: string, + sourceLabel: string, +): ImportedTheme { + const errors: ParseError[] = []; + const decoded = parse(source, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as unknown; + if (errors.length) { + throw new ThemeImportError( + `Invalid JSONC: ${printParseErrorCode(errors[0].error)}`, + ); + } + if (!isRecord(decoded)) { + throw new ThemeImportError("The theme file must contain a JSON object."); + } + if ("include" in decoded) { + throw new ThemeImportError( + "Theme include chains are not supported yet; import one standalone color-theme file.", + ); + } + if (!isRecord(decoded.colors)) { + throw new ThemeImportError("The theme file must define a colors object."); + } + + const colors: Record = {}; + for (const [name, value] of Object.entries(decoded.colors)) { + if (typeof value !== "string" || !HEX_COLOR.test(value.trim())) { + throw new ThemeImportError( + `Invalid color for ${name}; VS Code theme colors must use hexadecimal notation.`, + ); + } + colors[name] = normalizeHex(value.trim()); + } + + const base = inferThemeBase(decoded, colors); + const tokens: ImportedThemeTokens = { + ...(base === "dark" ? DARK_IMPORTED_THEME_BASE : LIGHT_IMPORTED_THEME_BASE), + }; + for (const token of IMPORTED_THEME_TOKEN_NAMES) { + const mapped = firstColor(colors, DIRECT_MAPPINGS[token] ?? []); + if (mapped) tokens[token] = mapped; + } + + const signal = firstColor(colors, ["focusBorder", "button.background"]); + if (signal) { + tokens["--signal-soft"] = withAlpha(signal, 0.16); + tokens["--signal-faint"] = withAlpha(signal, 0.08); + } + const success = firstColor(colors, [ + "testing.iconPassed", + "gitDecoration.addedResourceForeground", + ]); + if (success) tokens["--success-soft"] = withAlpha(success, 0.16); + const attention = firstColor(colors, [ + "editorWarning.foreground", + "list.warningForeground", + ]); + if (attention) tokens["--attention-soft"] = withAlpha(attention, 0.15); + const danger = firstColor(colors, [ + "errorForeground", + "editorError.foreground", + ]); + if (danger) tokens["--danger-soft"] = withAlpha(danger, 0.15); + const shadow = colors["widget.shadow"]; + if (shadow) { + tokens["--shadow-soft"] = `0 1px 2px ${withAlpha(shadow, 0.16)}`; + tokens["--shadow-float"] = + `0 18px 48px ${withAlpha(shadow, 0.32)}, ` + + `0 3px 10px ${withAlpha(shadow, 0.22)}`; + tokens["--shadow-menu"] = + `0 18px 42px ${withAlpha(shadow, 0.42)}, ` + + `0 3px 10px ${withAlpha(shadow, 0.26)}`; + } + + return { + name: themeName(decoded.name, sourceLabel), + base, + tokens, + }; +} + +export function sanitizeImportedTheme(value: unknown): ImportedTheme | null { + if (!isRecord(value) || !isRecord(value.tokens)) return null; + const base = value.base === "dark" ? "dark" : value.base === "light" ? "light" : null; + if (!base) return null; + const name = typeof value.name === "string" ? value.name.trim().slice(0, 120) : ""; + if (!name) return null; + const tokens: ImportedThemeTokens = { + ...(base === "dark" ? DARK_IMPORTED_THEME_BASE : LIGHT_IMPORTED_THEME_BASE), + }; + for (const token of IMPORTED_THEME_TOKEN_NAMES) { + const candidate = value.tokens[token]; + if (typeof candidate === "string" && safeStoredValue(candidate)) { + tokens[token] = candidate.trim(); + } + } + return { name, base, tokens }; +} + +export function applyImportedTheme( + theme: ImportedTheme | null, + root: HTMLElement, +): void { + for (const token of IMPORTED_THEME_TOKEN_NAMES) { + root.style.removeProperty(token); + } + root.style.removeProperty("--imported-color-scheme"); + if (!theme) return; + root.style.setProperty("--imported-color-scheme", theme.base); + for (const token of IMPORTED_THEME_TOKEN_NAMES) { + root.style.setProperty(token, theme.tokens[token]); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function firstColor( + colors: Record, + names: readonly string[], +): string | null { + for (const name of names) { + if (colors[name]) return colors[name]; + } + return null; +} + +function normalizeHex(value: string): string { + const raw = value.slice(1).toLowerCase(); + if (raw.length === 3 || raw.length === 4) { + return `#${[...raw].map((part) => `${part}${part}`).join("")}`; + } + return `#${raw}`; +} + +function withAlpha(value: string, opacity: number): string { + const normalized = normalizeHex(value); + const rgb = normalized.slice(0, 7); + const sourceAlpha = normalized.length === 9 ? parseInt(normalized.slice(7), 16) / 255 : 1; + const alpha = Math.round(255 * sourceAlpha * opacity) + .toString(16) + .padStart(2, "0"); + return `${rgb}${alpha}`; +} + +function inferThemeBase( + decoded: Record, + colors: Record, +): ImportedThemeBase { + const declared = String(decoded.type ?? decoded.uiTheme ?? "").toLowerCase(); + if (declared.includes("dark")) return "dark"; + if (declared.includes("light")) return "light"; + const background = colors["editor.background"]; + if (!background) return "light"; + const rgb = background + .slice(1, 7) + .match(/.{2}/g) + ?.map((part) => parseInt(part, 16) / 255); + if (!rgb || rgb.length !== 3) return "light"; + const luminance = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; + return luminance < 0.5 ? "dark" : "light"; +} + +function themeName(value: unknown, sourceLabel: string): string { + if (typeof value === "string" && value.trim()) return value.trim().slice(0, 120); + const fallback = sourceLabel + .replace(/\.(?:jsonc?|code-workspace)$/i, "") + .replace(/[-_]?color[-_]?theme$/i, "") + .trim(); + return (fallback || "Imported theme").slice(0, 120); +} + +function safeStoredValue(value: string): boolean { + const clean = value.trim().toLowerCase(); + return ( + clean.length > 0 && + clean.length <= 240 && + !/[;{}]/.test(clean) && + !clean.includes("url(") && + !clean.includes("var(") + ); +} diff --git a/desktop/src/app/useAppearance.ts b/desktop/src/app/useAppearance.ts index 7e95d2c8..55fa2a19 100644 --- a/desktop/src/app/useAppearance.ts +++ b/desktop/src/app/useAppearance.ts @@ -58,6 +58,8 @@ export interface AppearanceController { appearance: AppearanceState; /** Update one preference; the others are untouched. */ set(key: K, value: AppearanceState[K]): void; + /** Update related preferences atomically (used when importing a palette). */ + update(patch: Partial): void; reset(): void; } @@ -71,9 +73,14 @@ export function useAppearance(): AppearanceController { [], ); + const update = useCallback( + (patch: Partial) => commit({ ...state, ...patch }), + [], + ); + const reset = useCallback(() => commit({ ...APPEARANCE_DEFAULTS }), []); - return { appearance, set, reset }; + return { appearance, set, update, reset }; } /** Reset module state between tests. */ diff --git a/desktop/src/features/settings/AppearanceSettings.module.css b/desktop/src/features/settings/AppearanceSettings.module.css index 4cf85d57..c5f6b404 100644 --- a/desktop/src/features/settings/AppearanceSettings.module.css +++ b/desktop/src/features/settings/AppearanceSettings.module.css @@ -34,3 +34,47 @@ background: var(--surface-hover); font-weight: var(--weight-semibold, 600); } + +.importTheme { + display: grid; + gap: 5px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border-subtle); +} + +.importTheme label { + display: grid; + gap: 6px; + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); +} + +.importTheme input { + max-width: 100%; + color: var(--text-secondary); + font-size: var(--text-2xs); +} + +.importTheme input::file-selector-button { + min-height: 32px; + margin-right: 10px; + padding: 0 11px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-raised); + color: var(--text-primary); + cursor: pointer; +} + +.importTheme p { + margin: 0; + color: var(--text-tertiary); + font-size: var(--text-micro); + line-height: 1.5; +} + +.importTheme p[role="alert"] { + color: var(--danger); +} diff --git a/desktop/src/features/settings/AppearanceSettings.tsx b/desktop/src/features/settings/AppearanceSettings.tsx index 711cea31..c5d9ff5a 100644 --- a/desktop/src/features/settings/AppearanceSettings.tsx +++ b/desktop/src/features/settings/AppearanceSettings.tsx @@ -1,5 +1,5 @@ import { Monitor, Moon, Sun } from "lucide-react"; -import { useMemo, useId } from "react"; +import { useMemo, useId, useState } from "react"; import { APPEARANCE_DEFAULTS, @@ -13,6 +13,7 @@ import { availableFontCandidates, } from "../../app/fontCandidates"; import { useAppearance } from "../../app/useAppearance"; +import { parseVsCodeTheme, ThemeImportError } from "../../app/importedTheme"; import { useTranslation } from "react-i18next"; import styles from "../management/ManagementWorkspace.module.css"; import modeStyles from "./AppearanceSettings.module.css"; @@ -42,6 +43,7 @@ const THEME_LABELS: Record = { claude: "Claude — ivory & terracotta", "claude-dark": "Claude Dark — slate & terracotta", contrast: "High contrast — AAA", + imported: "Imported theme", }; function themeTranslationKey(preference: ThemePreference): string { @@ -76,15 +78,32 @@ const FONT_GROUPS = [ * display choices, not project configuration, so there is nothing to save. */ export function AppearanceSettings() { - const { appearance, set, reset } = useAppearance(); + const { appearance, set, update, reset } = useAppearance(); const { t } = useTranslation(); const fieldId = useId(); + const [importError, setImportError] = useState(null); // Probed once per mount: the set of installed fonts does not change while // the settings page is open. const installed = useMemo(() => availableFontCandidates(), []); const isDefault = APPEARANCE_SETTINGS.every( (setting) => appearance[setting.key] === APPEARANCE_DEFAULTS[setting.key], - ); + ) && appearance.importedTheme === null; + + const importTheme = async (file: File | null) => { + if (!file) return; + setImportError(null); + try { + if (file.size > 1_000_000) { + throw new ThemeImportError("Theme files must be 1 MB or smaller."); + } + const importedTheme = parseVsCodeTheme(await file.text(), file.name); + update({ importedTheme, theme: "imported" }); + } catch (cause) { + setImportError( + cause instanceof Error ? cause.message : "The theme could not be imported.", + ); + } + }; return (
@@ -137,10 +156,16 @@ export function AppearanceSettings() { } > {THEME_PREFERENCES.map((preference) => ( - ))} @@ -240,6 +265,39 @@ export function AppearanceSettings() { })} +
+ +

+ {appearance.importedTheme + ? t( + "settings.appearance.importedReady", + "Imported {{name}} · {{base}} base", + { + name: appearance.importedTheme.name, + base: appearance.importedTheme.base, + }, + ) + : t( + "settings.appearance.importHint", + "Reads one local JSON/JSONC color-theme file. Theme includes and syntax colors are not imported.", + )} +

+ {importError ?

{importError}

: null} +
+

{t( "settings.appearance.fontDescription", diff --git a/desktop/src/styles/tokens.css b/desktop/src/styles/tokens.css index 817d9b89..c0b5444d 100644 --- a/desktop/src/styles/tokens.css +++ b/desktop/src/styles/tokens.css @@ -492,6 +492,15 @@ --shadow-menu: 0 0 0 2px #000000; } +/* Imported themes provide a complete normalized palette as inline custom + properties. This selector keeps them in the same data-theme mechanism as + built-in palettes while the imported base controls native form chrome. */ +:root[data-theme="imported"] { + color-scheme: var(--imported-color-scheme); + color: var(--text-primary); + background: var(--surface-shell); +} + * { box-sizing: border-box; } diff --git a/desktop/src/styles/tokens.test.ts b/desktop/src/styles/tokens.test.ts index bc3ee5fb..946dc6ba 100644 --- a/desktop/src/styles/tokens.test.ts +++ b/desktop/src/styles/tokens.test.ts @@ -4,6 +4,11 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { THEME_PREFERENCES } from "../app/appearance"; +import { + DARK_IMPORTED_THEME_BASE, + IMPORTED_THEME_TOKEN_NAMES, + LIGHT_IMPORTED_THEME_BASE, +} from "../app/importedTheme"; // Vitest runs from the Vite root (desktop/); jsdom leaves import.meta.url as // a non-file URL, so resolve against the project root instead. @@ -83,7 +88,8 @@ describe("optional palettes", () => { // Two names are exempt: "system" sets no attribute at all, and "light" is // the base :root, so its block carries only `color-scheme`. const palettes = THEME_PREFERENCES.filter( - (theme) => theme !== "system" && theme !== "light", + (theme) => + theme !== "system" && theme !== "light" && theme !== "imported", ); it("checks every theme the picker offers", () => { @@ -105,6 +111,32 @@ describe("optional palettes", () => { }); }); +describe("imported palette contract", () => { + const rootDeclarations = declarationsAfter(":root {"); + const darkDeclarations = declarationsAfter(':root[data-theme="dark"]'); + const reference = [...darkDeclarations.keys()].filter((name) => + name.startsWith("--"), + ); + + it("fills exactly the same token set as every built-in palette", () => { + expect([...IMPORTED_THEME_TOKEN_NAMES].sort()).toEqual(reference.sort()); + }); + + it("keeps both fallback bases synchronized with tokens.css", () => { + for (const token of IMPORTED_THEME_TOKEN_NAMES) { + expect(LIGHT_IMPORTED_THEME_BASE[token]).toBe(rootDeclarations.get(token)); + expect(DARK_IMPORTED_THEME_BASE[token]).toBe(darkDeclarations.get(token)); + } + }); + + it("uses the ordinary data-theme selector", () => { + const imported = declarationsAfter(':root[data-theme="imported"]'); + expect(imported.get("color-scheme")).toBe("var(--imported-color-scheme)"); + expect(imported.get("color")).toBe("var(--text-primary)"); + expect(imported.get("background")).toBe("var(--surface-shell)"); + }); +}); + describe("terminal palette", () => { // xterm paints a canvas, so it takes colours as JS config and cannot read a // custom property. That makes TerminalPanel.tsx a second copy of these four