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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
Walter WangтаClaude authored and GitHub committed 2026-10-27 18:53:12 +08:00
1 джерело 50308a956d
коміт 3b608355a3
48 files changed
+1267 -1351

No files matched your search

+7 -8
Переглянути файл
@@ -9,14 +9,11 @@
*/
import tokensCss from "@/styles/tokens.css?raw";
import type { TextAttributesOverride } from "./editorSettings";
import { BUILTIN_THEMES, isBuiltinTheme, themeKind, type BuiltinThemeId } from "./themes";
export type SchemeId = "light" | "dark" | "dark-hc";
export type SchemeId = BuiltinThemeId;
export const SCHEMES: ReadonlyArray<{ id: SchemeId; name: string }> = [
{ id: "light", name: "Islands Light" },
{ id: "dark", name: "Islands Dark" },
{ id: "dark-hc", name: "High Contrast" },
];
export const SCHEMES: ReadonlyArray<{ id: SchemeId; name: string }> = BUILTIN_THEMES.map((t) => ({ id: t.id, name: t.label }));
export interface AttributeDef {
id: string;
@@ -167,7 +164,8 @@ function blocks(css: string): Array<{ selectors: string[]; decls: Vars }> {
function themeSelectors(id: SchemeId): string[] {
if (id === "light") return [':root[data-theme="light"]'];
if (id === "dark-hc") return [':root[data-theme="dark-hc"]', ':root[data-theme="dark"][data-contrast="high"]'];
return [':root[data-theme="dark"]'];
if (id === "dark") return [':root[data-theme="dark"]'];
return [`:root[data-theme="${themeKind(id)}"]`, ":root[data-color-theme]", `:root[data-color-theme="${id}"]`];
}
let source: string = tokensCss;
@@ -205,13 +203,14 @@ export function schemeTokens(id: SchemeId, css: string = source): Vars {
/** The scheme the editor shows for `setting` under the current theme. */
export function effectiveScheme(setting: string | undefined, root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null): SchemeId {
if (setting === "light" || setting === "dark" || setting === "dark-hc") return setting;
if (isBuiltinTheme(setting)) return setting;
return themeScheme(root);
}
/** Scheme belonging to the active IDE theme (`data-theme` / `data-contrast`). */
export function themeScheme(root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null): SchemeId {
if (!root) return "light";
if (isBuiltinTheme(root.dataset.colorTheme)) return root.dataset.colorTheme;
if (root.dataset.theme === "light") return "light";
return root.dataset.contrast === "high" || root.dataset.theme === "dark-hc" ? "dark-hc" : "dark";
}
+5 -1
Переглянути файл
@@ -3681,5 +3681,9 @@
"omitted for cycle": "因循环依赖而省略",
"version managed from {version}": "版本由 {version} 经依赖管理调整",
"Cannot find the declaration": "找不到声明",
"Cannot exclude {name}": "无法排除 {name}"
"Cannot exclude {name}": "无法排除 {name}",
"Solarized Light": "Solarized 浅色",
"Solarized Dark": "Solarized 深色",
"One Light": "One 浅色",
"One Dark": "One 深色"
}
+76
Переглянути файл
@@ -0,0 +1,76 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { BUILTIN_THEMES, isBuiltinTheme, themeKind } from "./themes";
import { _setSchemeSource, effectiveScheme, schemeCss, schemeTokens, themeScheme } from "./colorScheme";
import { applyThemeAttributes, resolveTheme, resolveThemeId } from "@/workbench/theme";
import { validateSettings } from "@/features/settings/model/schema";
import { applyPluginTheme } from "@/features/plugins/bridge";
const css = readFileSync("src/styles/tokens.css", "utf8");
_setSchemeSource(css);
afterEach(() => {
applyPluginTheme(null);
delete document.documentElement.dataset.colorTheme;
delete document.documentElement.dataset.contrast;
});
describe("built-in palettes", () => {
it.each(BUILTIN_THEMES)("preserves $id through validation and system resolution", ({ id, kind }) => {
const a = { theme: id, preferredLightTheme: id, preferredDarkTheme: id };
expect(validateSettings({ appearance: a }).appearance).toMatchObject(a);
expect(resolveThemeId(a, false)).toBe(id);
expect(resolveTheme(id, false)).toBe(kind);
expect(resolveThemeId({ ...a, syncWithOs: true }, true)).toBe(id);
expect(resolveThemeId({ ...a, theme: "system" }, false)).toBe(id);
applyThemeAttributes(id);
expect(document.documentElement.dataset.theme).toBe(kind);
expect(themeScheme()).toBe(id);
expect(effectiveScheme(id)).toBe(id);
expect(schemeCss("theme-default", undefined)).toBe("");
expect(schemeTokens(id).get("--bg-editor")).not.toContain("var(");
});
it("rejects unknown ids and retains legacy fallbacks", () => {
expect(isBuiltinTheme("not-a-theme")).toBe(false);
expect(resolveThemeId({ theme: "bad" }, true)).toBe("light");
expect(resolveThemeId({ theme: "system", preferredDarkTheme: "bad" }, false)).toBe("dark");
expect(themeKind("solarized-light")).toBe("light");
});
// Token coverage of every palette theme: settings/__tests__/contrast.test.ts.
it("resolves editor scheme tokens from the palettes", () => {
expect(schemeTokens("solarized-light").get("--bg-editor")).toBe("#fdf6e3");
expect(schemeTokens("solarized-dark").get("--bg-editor")).toBe("#002b36");
expect(schemeTokens("solarized-dark").get("--syn-string")).toBe("#2aa198");
expect(schemeTokens("solarized-light").get("--ansi-bright-blue")).toBe("#839496");
expect(schemeTokens("one-light").get("--syn-keyword")).toBe("hsl(301, 63%, 40%)");
expect(schemeTokens("one-dark").get("--syn-string")).toBe("hsl(95, 38%, 62%)");
});
it("keeps explicit editor schemes and per-scheme edits independent of UI palettes", () => {
applyThemeAttributes("solarized-light");
expect(schemeCss("one-dark", undefined)).toContain("--bg-editor: hsl(220, 13%, 18%) !important");
expect(schemeCss("theme-default", { "solarized-light": { keyword: { foreground: "#123456" } } })).toContain(
"--syn-keyword: #123456 !important",
);
applyThemeAttributes("one-light");
expect(schemeCss("theme-default", { "solarized-light": { keyword: { foreground: "#123456" } } })).toBe("");
});
it("clears the palette for plugins and restores it when the plugin is removed", () => {
const root = document.documentElement;
applyThemeAttributes("one-dark", root);
root.dataset.pluginThemeKind = "light";
applyThemeAttributes("one-dark", root);
expect(root.dataset.colorTheme).toBeUndefined();
expect(root.dataset.theme).toBe("light");
delete root.dataset.pluginThemeKind;
applyThemeAttributes("one-dark", root);
expect(root.dataset.colorTheme).toBe("one-dark");
applyThemeAttributes("dark-hc", root);
expect(root.dataset.colorTheme).toBeUndefined();
expect(root.dataset.contrast).toBe("high");
applyThemeAttributes("solarized-dark", root);
expect(root.dataset.contrast).toBeUndefined();
});
});
+19
Переглянути файл
@@ -0,0 +1,19 @@
/** Stable built-in ids shared by settings, quick switch and editor schemes. */
export const BUILTIN_THEMES = [
{ id: "light", label: "Islands Light", kind: "light" },
{ id: "dark", label: "Islands Dark", kind: "dark" },
{ id: "dark-hc", label: "High Contrast", kind: "dark" },
{ id: "solarized-light", label: "Solarized Light", kind: "light" },
{ id: "solarized-dark", label: "Solarized Dark", kind: "dark" },
{ id: "one-light", label: "One Light", kind: "light" },
{ id: "one-dark", label: "One Dark", kind: "dark" },
] as const;
export type BuiltinThemeId = (typeof BUILTIN_THEMES)[number]["id"];
export const BUILTIN_THEME_IDS: readonly BuiltinThemeId[] = BUILTIN_THEMES.map((t) => t.id);
export function isBuiltinTheme(value: unknown): value is BuiltinThemeId {
return typeof value === "string" && (BUILTIN_THEME_IDS as readonly string[]).includes(value);
}
export function themeKind(id: BuiltinThemeId): "light" | "dark" {
return BUILTIN_THEMES.find((t) => t.id === id)!.kind;
}