feat(themes): Solarized and One theme plugins (#92)

Solarized Light/Dark and One Light/Dark ship as theme plugins (plugins/theme-solarized, plugins/theme-one) with complete UI tokens, editor schemes and terminal colours, and the original palettes' MIT notices. Installing a plugin now also copies its LICENSE/NOTICE/COPYING.

Theme handling that benefits every plugin theme:
- The last plugin theme is restored before first paint (splash, workbench); the native window background follows the theme.
- HSL / color-mix() / var() colours are resolved to RGB for native windows, the Settings window and Mermaid.
- Mermaid, the terminal and editor schemes follow switches between two themes of the same kind.
- An explicitly chosen editor scheme wins over a plugin theme's editor colours.

Supersedes #85.

Co-authored-by: bghtyu <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-aiтаbghtyu authored and GitHub committed 2026-10-27 17:53:07 +08:00
1 джерело 9d8280104f
коміт 24330192c0
43 files changed
+2035 -180

No files matched your search

+3 -3
Переглянути файл
@@ -144,7 +144,7 @@ describe("store", () => {
it("selects and clears a plugin theme", async () => {
await refreshPlugins();
await selectPluginTheme("theme-solarized-dark", "solarized-dark");
await selectPluginTheme("theme-solarized", "solarized-dark");
expect(usePlugins.getState().activeTheme?.theme.id).toBe("solarized-dark");
await selectPluginTheme(null, null);
expect(usePlugins.getState().activeTheme).toBeNull();
@@ -238,7 +238,7 @@ describe("PluginsPage", () => {
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument());
expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled();
await user.click(within(list).getByText("Solarized Dark Theme"));
await user.click(within(list).getByText("Solarized Themes"));
await user.click(screen.getByRole("button", { name: "Apply theme Solarized Dark" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use Default Theme" })).toBeInTheDocument());
@@ -309,7 +309,7 @@ describe("catalog helpers", () => {
expect(catalogState(entry, [plugin({ id: "hello", version: "0.0.9" })])).toBe("update");
expect(catalogState(entry, [plugin({ id: "hello", version: "0.1.0" })])).toBe("installed");
expect(catalogState(entry, [plugin({ id: "hello", source: "dev" })])).toBe("dev");
expect(filterCatalog(sampleCatalog(), "solarized").map((c) => c.id)).toEqual(["theme-solarized-dark"]);
expect(filterCatalog(sampleCatalog(), "solarized").map((c) => c.id)).toEqual(["theme-solarized"]);
});
it("disables and re-enables all downloaded plugins, leaving dev plugins alone", async () => {
+153
Переглянути файл
@@ -0,0 +1,153 @@
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ActiveTheme } from "@/bindings";
import { STARTUP_THEME_KEY, syncWindowBackground } from "@/workbench/theme";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
PLUGIN_THEME_CACHE_KEY,
PLUGIN_THEME_STYLE_ID,
trackPluginTheme,
type CachedPluginTheme,
} from "../bridge";
import { usePlugins } from "../store";
const BOOT_JS = readFileSync("public/boot.js", "utf8");
/** Runs public/boot.js as the next window would, before any app code. */
const boot = () => new Function(BOOT_JS)();
const ONE_DARK: ActiveTheme = {
pluginId: "theme-one",
theme: {
id: "one-dark",
name: "One Dark",
kind: "dark",
tokens: {
"--bg-frame": "hsl(220, 13%, 15%)",
"--fg-muted": "hsl(220, 9%, 55%)",
"--accent": "hsl(220, 100%, 66%)",
},
},
};
const SOLARIZED_LIGHT: ActiveTheme = {
pluginId: "theme-solarized",
theme: { id: "solarized-light", name: "Solarized Light", kind: "light", tokens: { "--bg-frame": "#eee8d5" } },
};
/** Computed style as a browser would resolve the active plugin theme. */
function computedColors(values: Record<string, string>) {
return vi.spyOn(globalThis, "getComputedStyle").mockImplementation(
() =>
({
getPropertyValue: (name: string) => values[name] ?? "",
color: "",
}) as unknown as CSSStyleDeclaration,
);
}
function resetRoot() {
const root = document.documentElement;
for (const key of Object.keys(root.dataset)) delete root.dataset[key];
document.getElementById(PLUGIN_THEME_STYLE_ID)?.remove();
document.getElementById("openidea-boot-colors")?.remove();
}
let backend: PluginsBackend;
beforeEach(() => {
localStorage.clear();
resetRoot();
backend = createMemoryPluginsBackend();
setPluginsBackend(backend);
usePlugins.setState({ plugins: [], activeTheme: null, loaded: false, error: null });
});
afterEach(() => {
vi.restoreAllMocks();
applyPluginTheme(null);
setPluginsBackend(null);
localStorage.clear();
resetRoot();
});
describe("plugin theme at start-up", () => {
it("restores the last plugin theme and splash colours before the app loads", () => {
const style = computedColors({ "--bg-frame": "#21252b", "--fg-muted": "#828997", "--accent": "#5c8aff" });
const stop = trackPluginTheme();
usePlugins.setState({ loaded: true, activeTheme: ONE_DARK });
syncWindowBackground();
stop();
style.mockRestore();
resetRoot();
boot();
const root = document.documentElement;
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
expect(root.dataset.pluginThemeKind).toBe("dark");
expect(root.dataset.theme).toBe("dark");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)?.textContent).toContain("--bg-frame: hsl(220, 13%, 15%);");
expect(document.getElementById("openidea-boot-colors")?.textContent).toBe(
":root { --boot-frame: #21252b; --boot-fg: #828997; --boot-accent: #5c8aff; }",
);
});
it("keeps the restored theme until the plugin list arrives, then follows it", () => {
const cached: CachedPluginTheme = {
id: "theme-one/one-dark",
kind: "dark",
css: ':root[data-plugin-theme]:not([data-plugin-theme=""]) {\n --bg-frame: #21252b;\n}\n',
};
localStorage.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached));
boot();
const root = document.documentElement;
const stop = trackPluginTheme();
// Nothing listed yet: the boot theme stays.
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).not.toBeNull();
// A failed listing doesn't drop it either.
usePlugins.setState({ loaded: true, error: "backend unavailable" });
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
usePlugins.setState({ error: null, activeTheme: SOLARIZED_LIGHT });
expect(root.dataset.pluginTheme).toBe("theme-solarized/solarized-light");
expect(root.dataset.theme).toBe("light");
expect(JSON.parse(localStorage.getItem(PLUGIN_THEME_CACHE_KEY)!)).toMatchObject({
id: "theme-solarized/solarized-light",
kind: "light",
});
// The plugin was disabled or uninstalled meanwhile.
usePlugins.setState({ activeTheme: null });
expect(root.dataset.pluginTheme).toBeUndefined();
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).toBeNull();
expect(localStorage.getItem(PLUGIN_THEME_CACHE_KEY)).toBeNull();
stop();
});
it("ignores a cached stylesheet that could load resources", () => {
localStorage.setItem(
PLUGIN_THEME_CACHE_KEY,
JSON.stringify({ id: "p/t", kind: "dark", css: ":root { --bg-app: url(https://example.com/x) }" }),
);
localStorage.setItem(STARTUP_THEME_KEY, JSON.stringify({ theme: "light", frame: "red; color: blue" }));
boot();
expect(document.documentElement.dataset.pluginTheme).toBeUndefined();
expect(document.documentElement.dataset.theme).toBe("light");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).toBeNull();
expect(document.getElementById("openidea-boot-colors")).toBeNull();
});
it("reports the resolved frame colour once per selected theme", () => {
const setFrame = vi.spyOn(backend, "setThemeFrame");
computedColors({ "--bg-frame": "rgb(33, 37, 43)" });
const stop = trackPluginTheme();
usePlugins.setState({ loaded: true, activeTheme: ONE_DARK });
usePlugins.setState({ activeTheme: { ...ONE_DARK } });
expect(setFrame.mock.calls).toEqual([["#21252b"]]);
// Selecting it again after the default theme reports it again: the backend forgot it.
usePlugins.setState({ activeTheme: null });
usePlugins.setState({ activeTheme: ONE_DARK });
expect(setFrame).toHaveBeenCalledTimes(2);
stop();
});
});