feat(themes): Built-in Solarized and One light/dark themes (#85)

Adds Solarized Light, Solarized Dark, One Light and One Dark as built-in themes across the workbench, editor, terminal and Mermaid, keeping the original palettes (MIT notices in THIRD_PARTY_LICENSES.md, mapping in docs/themes.md).

- Native window colours are read from tokens.css at build time (`theme_frame.rs`) instead of a second colour table.
- Startup restores the built-in palette before first paint.
- Editor schemes, the terminal and Mermaid refresh on switches between themes of the same kind.
- The plugin packages of the same themes are removed.
- A test requires every built-in palette theme to define each token (only listed ones may inherit from the base theme), so new tokens can't silently miss them.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
Walter Wang
2026-10-27 18:53:12 +08:00
committed by GitHub
co-authored by Claude
parent 50308a956d
commit 3b608355a3
48 changed files with 1267 additions and 1351 deletions
+1 -68
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
convertIcls,
@@ -77,73 +77,6 @@ describe("theme plugin template", () => {
});
});
/** Theme packages in plugins/: every theme they contribute, with its editor scheme. */
function pluginThemes() {
return readdirSync("plugins", { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(`plugins/${d.name}/plugin.toml`))
.flatMap((d) => {
const dir = `plugins/${d.name}`;
const manifest = read(`${dir}/plugin.toml`);
return manifest
.split("[[contributes.themes]]")
.slice(1)
.map((entry) => ({
dir,
manifest,
path: /^path\s*=\s*"([^"]+)"/m.exec(entry)?.[1],
scheme: /^editor-scheme\s*=\s*"([^"]+)"/m.exec(entry)?.[1],
}));
});
}
describe("theme plugins in plugins/", () => {
const themes = pluginThemes();
it("include Solarized and One, light and dark", () => {
expect(themes.map((t) => `${t.dir}/${t.path}`)).toEqual(
expect.arrayContaining([
"plugins/theme-solarized/themes/solarized-light.theme.json",
"plugins/theme-solarized/themes/solarized-dark.theme.json",
"plugins/theme-one/themes/one-light.theme.json",
"plugins/theme-one/themes/one-dark.theme.json",
]),
);
});
it.each(themes.map((t) => [`${t.dir}/${t.path}`, t] as const))(
"%s sets every UI token and its scheme every editor token",
(_name, t) => {
// A failure here means tokens.css gained (or lost) a themeable token: add it to the theme too.
expect(t.path && t.scheme, "theme and editor-scheme paths").toBeTruthy();
expect(t.manifest).not.toMatch(/^wasm\s*=/m);
const ui = JSON.parse(stripJsonComments(read(`${t.dir}/${t.path}`))) as {
id: string;
name: string;
kind: string;
tokens: Record<string, string>;
};
const ed = JSON.parse(stripJsonComments(read(`${t.dir}/${t.scheme}`))) as { tokens: Record<string, string> };
expect(ui.id).toMatch(/^[a-z0-9][a-z0-9._-]*$/);
expect(ui.name.trim()).not.toBe("");
expect(["light", "dark"]).toContain(ui.kind);
expect(sorted(Object.keys(ui.tokens))).toEqual(sorted(sets.ui));
expect(sorted(Object.keys(ed.tokens))).toEqual(sorted(sets.scheme));
for (const [k, v] of Object.entries({ ...ui.tokens, ...ed.tokens })) {
expect(/[;{}<>\\\n]|url\(|image\(/i.test(v), `${k}: ${v}`).toBe(false);
// var() may only point at tokens the IDE defines.
for (const [, ref] of v.matchAll(/var\((--[a-z0-9-]+)/g)) expect(css, `${k} → ${ref}`).toContain(`${ref}:`);
}
},
);
it("ship the licence of the palettes they port", () => {
for (const dir of new Set(themes.map((t) => t.dir))) {
if (dir === "plugins/theme-solarized" || dir === "plugins/theme-one")
expect(read(`${dir}/LICENSE`), dir).toContain("MIT License");
}
});
});
describe("IntelliJ theme import", () => {
const themeJson = `{
// comment