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

+34
Переглянути файл
@@ -32,6 +32,40 @@ describe("color schemes", () => {
expect(pythonItalic).toContain("--syn-python-static-italic: italic !important");
});
it("applies an explicit scheme over a plugin theme of the same base", () => {
const root = document.documentElement;
root.dataset.theme = "dark";
root.dataset.pluginTheme = "theme-one/one-dark";
try {
// "Theme default" keeps the plugin theme's own editor colours.
expect(schemeCss("theme-default", undefined, root)).toBe("");
expect(schemeCss("dark", undefined, root)).toContain("--syn-keyword: #cf8e6d !important");
delete root.dataset.pluginTheme;
expect(schemeCss("dark", undefined, root)).toBe("");
} finally {
delete root.dataset.pluginTheme;
}
});
it("re-applies the scheme when a plugin theme of the same base is selected", async () => {
const root = document.documentElement;
root.dataset.theme = "dark";
const previous = useProject.getState().settings;
useProject.setState({ settings: { editor: { colorScheme: "dark" } } as unknown as Settings });
const dispose = installColorScheme();
const sheet = () => document.getElementById("openidea-editor-color-scheme")?.textContent ?? "";
try {
expect(sheet()).toBe("");
root.dataset.pluginTheme = "theme-one/one-dark";
await Promise.resolve();
expect(sheet()).toContain("--syn-keyword: #cf8e6d !important");
} finally {
dispose();
delete root.dataset.pluginTheme;
useProject.setState({ settings: previous });
}
});
it("keeps editor weights out of the schemes", () => {
for (const id of ["light", "dark", "dark-hc"] as const) {
expect(schemeTokens(id).has("--fw-code")).toBe(false);
+4 -1
Переглянути файл
@@ -267,7 +267,10 @@ const SAFE = /^[^;{}<>]*$/;
export function schemeCss(setting: string | undefined, overrides: Record<string, Record<string, TextAttributesOverride>> | undefined, root?: HTMLElement | null): string {
const id = effectiveScheme(setting, root);
const decls: Array<[string, string]> = [];
if (id !== themeScheme(root)) {
// A plugin theme brings its own editor colours: an explicit scheme replaces them even on the same base.
const el = root === undefined ? (typeof document !== "undefined" ? document.documentElement : null) : root;
const pluginTheme = !!el?.dataset.pluginTheme && id === setting;
if (id !== themeScheme(root) || pluginTheme) {
const tokens = schemeTokens(id);
for (const [k, v] of tokens) if (SCHEME_VARS.has(k) || k.startsWith("--syn-")) decls.push([k, v]);
// The editor surface follows the scheme, not the island colour of the UI theme.
+37
Переглянути файл
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseColor, resolveTokenColor, toHex, toRgb } from "./cssColor";
afterEach(() => vi.restoreAllMocks());
describe("css colours", () => {
it("parses hex, rgb() and color(srgb) and formats them back", () => {
expect(parseColor("#eee8d5")).toEqual([238, 232, 213, 1]);
expect(parseColor("#fff8")).toEqual([255, 255, 255, 0x88 / 255]);
expect(parseColor("rgb(33, 37, 43)")).toEqual([33, 37, 43, 1]);
expect(parseColor("rgba(0 0 0 / 50%)")).toEqual([0, 0, 0, 0.5]);
expect(parseColor("color(srgb 0.5 0.25 0.75 / 0.8)")).toEqual([127.5, 63.75, 191.25, 0.8]);
expect(parseColor("hsl(220, 13%, 15%)")).toBeNull();
expect(toHex([33, 37, 43, 1])).toBe("#21252b");
expect(toHex([127.5, 63.75, 191.25, 0.8])).toBe("#8040bfcc");
expect(toRgb([33, 37, 43, 1])).toBe("rgb(33, 37, 43)");
expect(toRgb([0, 0, 0, 0.5])).toBe("rgba(0, 0, 0, 0.5)");
});
it("resolves tokens written as hsl(), color-mix() or var() through the browser", () => {
const root = document.documentElement;
const tokens: Record<string, string> = { "--hex": "#073642", "--hsl": "hsl(220, 13%, 15%)", "--none": "" };
vi.spyOn(globalThis, "getComputedStyle").mockImplementation(
(el) =>
(el === root
? { getPropertyValue: (n: string) => tokens[n] ?? "" }
: {
color: (el as HTMLElement).style.color === "var(--hsl)" ? "rgb(33, 37, 43)" : "",
}) as unknown as CSSStyleDeclaration,
);
expect(resolveTokenColor("--hex", root)).toEqual([7, 54, 66, 1]);
expect(resolveTokenColor("--hsl", root)).toEqual([33, 37, 43, 1]);
expect(resolveTokenColor("--none", root)).toBeNull();
// The probe element is removed again.
expect(root.querySelector("span[hidden]")).toBeNull();
});
});
+67
Переглянути файл
@@ -0,0 +1,67 @@
/**
* Design tokens as concrete colours. Themes may write tokens as `hsl()`,
* `color-mix()` or `var()`; consumers outside CSS (the native window, Mermaid,
* xterm) need RGB, so a token is resolved by the browser and parsed here.
*/
/** Red, green, blue (0–255) and alpha (0–1). */
export type Rgba = [number, number, number, number];
/** Parses `#rgb[a]`, `#rrggbb[aa]`, `rgb[a](…)` and `color(srgb …)`; `null` when unknown. */
export function parseColor(input: string): Rgba | null {
const s = input.trim().toLowerCase();
if (s.startsWith("#")) {
const h = s.slice(1);
if (!/^[0-9a-f]+$/.test(h)) return null;
if (h.length === 3 || h.length === 4) {
const [r, g, b, a = "f"] = h.split("") as [string, string, string, string?];
return [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16), parseInt(a + a, 16) / 255];
}
if (h.length === 6 || h.length === 8) {
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), a];
}
return null;
}
let m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(s);
if (m) {
const alpha = m[4] === undefined ? 1 : m[4].endsWith("%") ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
}
m = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/.exec(s);
if (m) {
return [Number(m[1]) * 255, Number(m[2]) * 255, Number(m[3]) * 255, m[4] === undefined ? 1 : Number(m[4])];
}
return null;
}
/** The computed colour of token `name` on `root`, whatever syntax the theme wrote it in. */
export function resolveTokenColor(name: string, root: HTMLElement = document.documentElement): Rgba | null {
if (typeof getComputedStyle === "undefined") return null;
const raw = getComputedStyle(root).getPropertyValue(name).trim();
if (!raw) return null;
const direct = parseColor(raw);
if (direct) return direct;
const probe = root.ownerDocument.createElement("span");
probe.hidden = true;
probe.style.color = `var(${name})`;
root.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return parseColor(color);
}
const byte = (v: number) => Math.min(255, Math.max(0, Math.round(v)));
/** `#rrggbb`, or `#rrggbbaa` when translucent. */
export function toHex([r, g, b, a]: Rgba): string {
const parts = [r, g, b].map(byte);
if (byte(a * 255) !== 255) parts.push(byte(a * 255));
return `#${parts.map((x) => x.toString(16).padStart(2, "0")).join("")}`;
}
/** `rgb(r, g, b)`, or `rgba(r, g, b, a)` when translucent. */
export function toRgb([r, g, b, a]: Rgba): string {
const [rr, gg, bb] = [r, g, b].map(byte);
return a < 1 ? `rgba(${rr}, ${gg}, ${bb}, ${Math.round(a * 1000) / 1000})` : `rgb(${rr}, ${gg}, ${bb})`;
}