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

+5 -5
Переглянути файл
@@ -144,8 +144,8 @@ describe("store", () => {
it("selects and clears a plugin theme", async () => {
await refreshPlugins();
await selectPluginTheme("theme-solarized", "solarized-dark");
expect(usePlugins.getState().activeTheme?.theme.id).toBe("solarized-dark");
await selectPluginTheme("theme-harbor-night", "harbor-night");
expect(usePlugins.getState().activeTheme?.theme.id).toBe("harbor-night");
await selectPluginTheme(null, null);
expect(usePlugins.getState().activeTheme).toBeNull();
});
@@ -238,8 +238,8 @@ 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 Themes"));
await user.click(screen.getByRole("button", { name: "Apply theme Solarized Dark" }));
await user.click(within(list).getByText("Harbor Night Theme"));
await user.click(screen.getByRole("button", { name: "Apply theme Harbor Night" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use Default Theme" })).toBeInTheDocument());
await user.type(screen.getByLabelText("Filter plugins"), "toy");
@@ -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"]);
expect(filterCatalog(sampleCatalog(), "harbor").map((c) => c.id)).toEqual(["theme-harbor-night"]);
});
it("disables and re-enables all downloaded plugins, leaving dev plugins alone", async () => {
+18 -32
Переглянути файл
@@ -76,35 +76,21 @@ function ensureSubscribed() {
// ---------------------------------------------------------------------------
// Browser simulation: the bundled sample plugins, with `hello` implemented in JS.
const SOLARIZED: ActiveTheme["theme"][] = [
// templates/theme-plugin, abridged.
const SAMPLE_THEMES: ActiveTheme["theme"][] = [
{
id: "solarized-light",
name: "Solarized Light",
kind: "light",
tokens: {
"--bg-app": "#fdf6e3",
"--bg-island": "#fdf6e3",
"--bg-frame": "#eee8d5",
"--bg-elev": "#eee8d5",
"--fg": "#657b83",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
},
},
{
id: "solarized-dark",
name: "Solarized Dark",
id: "harbor-night",
name: "Harbor Night",
kind: "dark",
tokens: {
"--bg-app": "#002b36",
"--bg-island": "#002b36",
"--bg-frame": "#073642",
"--bg-elev": "#073642",
"--fg": "#839496",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
"--bg-app": "#14171c",
"--bg-island": "#14171c",
"--bg-frame": "#1f252e",
"--bg-elev": "#1b2027",
"--fg": "#c9d1dc",
"--accent": "#2b9e93",
"--syn-keyword": "#e0a46e",
"--syn-string": "#9ccc7a",
},
},
];
@@ -140,14 +126,14 @@ function samplePlugins(): PluginInfo[] {
},
{
...base,
id: "theme-solarized",
name: "Solarized Themes",
id: "theme-harbor-night",
name: "Harbor Night Theme",
version: "1.0.0",
description: "Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized",
description: "Example dark theme: slate surfaces, a teal accent and a warm syntax palette.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-harbor-night",
status: "static",
hasCode: false,
themes: SOLARIZED.map(({ id, name, kind }) => ({ id, name, kind })),
themes: SAMPLE_THEMES.map(({ id, name, kind }) => ({ id, name, kind })),
},
{
...base,
@@ -201,7 +187,7 @@ export function createMemoryPluginsBackend(
const activeTheme = (): ActiveTheme | null => {
if (!theme) return null;
const p = plugins.find((x) => x.id === theme?.pluginId);
const t = SOLARIZED.find((x) => x.id === theme?.themeId);
const t = SAMPLE_THEMES.find((x) => x.id === theme?.themeId);
if (!p?.enabled || !t) return null;
return { pluginId: p.id, theme: t };
};
+47
Переглянути файл
@@ -2,6 +2,7 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { contrastRatio, isColorValue, readTokens } from "../theme/contrast";
import { BUILTIN_THEMES } from "@/platform/themes";
const css = readFileSync(`${process.cwd()}/src/styles/tokens.css`, "utf8");
const dark = readTokens(css, ":root");
@@ -116,3 +117,49 @@ describe("theme tokens", () => {
expect(contrastRatio("#000", "#fff")).toBeCloseTo(21, 5);
});
});
/**
* Tokens the palette themes (Solarized, One) take from their light / dark base
* on purpose: colours the user assigns (project and data source colours), the
* platform's caption close button, and the tint strength.
*/
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
describe("palette themes", () => {
const ISLANDS = ["light", "dark", "dark-hc"];
const palettes = BUILTIN_THEMES.filter((t) => !ISLANDS.includes(t.id));
const shared = readTokens(css, ":root[data-color-theme]");
// Every themeable token (the Islands Light block lists them all) and every plain colour of :root.
const required = [
...new Set([
...Object.keys(readTokens(css, ':root[data-theme="light"]')),
...Object.entries(dark)
.filter(([, v]) => isColorValue(v) && !v.includes("var("))
.map(([k]) => k),
]),
];
it("have a tokens.css block for every palette theme id, and no other", () => {
const blocks = [...css.matchAll(/:root\[data-color-theme="([a-z0-9-]+)"\]/g)].map((m) => m[1]);
expect([...new Set(blocks)].sort()).toEqual(palettes.map((t) => t.id).sort());
expect(palettes.length).toBeGreaterThanOrEqual(4);
});
it.each(palettes)("$id defines every token or inherits it from its $kind base on purpose", ({ id, kind }) => {
// A failure here means tokens.css gained a token: map it in the :root[data-color-theme]
// block (or the theme's own), or add it to INHERITED_FROM_BASE if the base's value is right.
const own = { ...shared, ...readTokens(css, `:root[data-color-theme="${id}"]`) };
expect(required.length).toBeGreaterThan(200);
expect(required.filter((k) => !(k in own) && !INHERITED_FROM_BASE.test(k))).toEqual([]);
const base = kind === "light" ? light : dark;
expect(required.filter((k) => INHERITED_FROM_BASE.test(k) && !(k in base))).toEqual([]);
// Every palette role the shared mapping uses is set by the theme.
const refs = Object.values(own)
.join(" ")
.matchAll(/var\((--palette-[a-z0-9-]+)/g);
const roles = new Set([...refs].map((m) => m[1]!));
expect([...roles].filter((r) => !(r in own))).toEqual([]);
for (const t of ["--chart-series", "--bg-frame", "--fg-muted", "--accent", "--ansi-red", "--syn-keyword"])
expect(own[t], `${id} ${t}`).toBeTruthy();
});
});
+4 -2
Переглянути файл
@@ -4,11 +4,13 @@
* `ide-core::settings` defaults; validation never throws.
*/
import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes";
export type Antialiasing = "subpixel" | "greyscale" | "none";
export type OpenProjectIn = "ask" | "new" | "same";
export type NotificationDisplay = "balloon" | "sticky" | "none";
/** Themes selectable as "preferred" when syncing with the OS. */
export type ConcreteTheme = "light" | "dark" | "dark-hc";
export type ConcreteTheme = BuiltinThemeId;
export interface AppearanceExtras {
syncWithOs: boolean;
@@ -102,7 +104,7 @@ const int = (v: unknown, d: number, { min, max }: { min: number; max: number })
typeof v === "number" && Number.isFinite(v) ? Math.round(Math.min(max, Math.max(min, v))) : d;
const AA: readonly Antialiasing[] = ["subpixel", "greyscale", "none"];
const CONCRETE: readonly ConcreteTheme[] = ["light", "dark", "dark-hc"];
const CONCRETE = BUILTIN_THEME_IDS;
const DISPLAYS: readonly NotificationDisplay[] = ["balloon", "sticky", "none"];
export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
+3 -2
Переглянути файл
@@ -33,8 +33,9 @@ import {
type GeneralExtras,
type NotificationsValid,
} from "./behaviorSchema";
import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes";
export type ThemeId = "dark" | "light" | "system" | "dark-hc";
export type ThemeId = BuiltinThemeId | "system";
export type LocaleId = "en" | "zh";
/** A user keymap (Keymap › Duplicate…): its own overrides on top of a bundled preset. */
@@ -155,7 +156,7 @@ export const LIMITS = {
localHistoryMaxMb: { min: 16, max: 16384 },
} as const;
const THEMES: readonly ThemeId[] = ["dark", "light", "system", "dark-hc"];
const THEMES: readonly ThemeId[] = [...BUILTIN_THEME_IDS, "system"];
const LOCALES: readonly LocaleId[] = ["en", "zh"];
export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
+4 -6
Переглянути файл
@@ -29,13 +29,11 @@ import type { PageSpec } from "./spec";
import styles from "./Behavior.module.css";
import { useT } from "@/platform/i18n";
import { BUILTIN_THEMES } from "@/platform/themes";
export const APPEARANCE_GROUP = "Appearance & Behavior";
const THEMES: SelectOption<ConcreteTheme>[] = [
{ value: "light", label: "Islands Light" },
{ value: "dark", label: "Islands Dark" },
{ value: "dark-hc", label: "High Contrast" },
];
const THEMES: SelectOption<ConcreteTheme>[] = BUILTIN_THEMES.map((t) => ({ value: t.id, label: t.label }));
/** Checkbox bound to a key, optionally disabled. */
function BoundToggle({ k, label, hint, disabled }: { k: string; label: string; hint?: ReactNode; disabled?: boolean }) {
@@ -500,7 +498,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
{
kind: "custom",
label: "Theme",
keywords: ["Sync with OS", "Islands Light", "Islands Dark", "High Contrast", "preferred theme"],
keywords: ["Sync with OS", ...BUILTIN_THEMES.map((t) => t.label), "preferred theme"],
component: ThemeRow,
},
{
+2 -2
Переглянути файл
@@ -195,10 +195,10 @@ export function activate(): () => void {
terminalRuntime()?.applyThemeToAll();
terminalRuntime()?.applyThemeToEmbedded();
});
// A plugin theme switch keeps data-theme when both themes share a light / dark base.
// A palette or plugin theme switch keeps data-theme when both themes share a light / dark base.
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme", "style"],
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
});
disposers.push(() => observer.disconnect());
}
+24 -2
Переглянути файл
@@ -108,16 +108,38 @@ describe("mermaid in the markdown preview", () => {
expect(ids.size).toBe(2);
});
it("re-renders when switching between built-in palette themes of the same light / dark base", async () => {
const root = document.documentElement;
const previous = root.dataset.colorTheme;
const changed = vi.fn();
const off = subscribeTheme(changed);
try {
root.dataset.colorTheme = "one-dark";
const firstKey = themeKey();
await renderMermaidIn(preview(SEQ)).done;
root.dataset.colorTheme = "solarized-dark";
await Promise.resolve();
expect(changed).toHaveBeenCalled();
expect(themeKey()).not.toBe(firstKey);
await renderMermaidIn(preview(SEQ)).done;
expect(m.render).toHaveBeenCalledTimes(2);
} finally {
off();
if (previous === undefined) delete root.dataset.colorTheme;
else root.dataset.colorTheme = previous;
}
});
it("re-renders when switching between plugin themes of the same light / dark base", async () => {
const root = document.documentElement;
const changed = vi.fn();
const off = subscribeTheme(changed);
try {
root.dataset.theme = "dark";
root.dataset.pluginTheme = "theme-one/one-dark";
root.dataset.pluginTheme = "acme-themes/night";
const firstKey = themeKey();
await renderMermaidIn(preview(SEQ)).done;
root.dataset.pluginTheme = "theme-solarized/solarized-dark";
root.dataset.pluginTheme = "acme-themes/dusk";
await Promise.resolve();
expect(changed).toHaveBeenCalled();
expect(themeKey()).not.toBe(firstKey);
+3 -3
Переглянути файл
@@ -30,8 +30,8 @@ function loadMermaid(): Promise<Mermaid> {
/** Theme identity: anything that changes the colours read below. */
export function themeKey(): string {
const root = document.documentElement;
const { theme = "", contrast = "", pluginTheme = "" } = root.dataset;
return `${theme}|${contrast}|${pluginTheme}|${root.style.getPropertyValue("--accent")}`;
const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset;
return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${root.style.getPropertyValue("--accent")}`;
}
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
@@ -40,7 +40,7 @@ export function subscribeTheme(cb: () => void): () => void {
const observer = new MutationObserver(cb);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme", "style"],
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
});
return () => observer.disconnect();
}
+4 -7
Переглянути файл
@@ -13,14 +13,11 @@ import { notifications } from "@/workbench/contributions";
import { ACCENT_PRESETS, DEFAULT_ACCENT } from "@/workbench/theme";
import { reopenEnabled, setReopenPreference, useSession } from "./session";
import styles from "./Welcome.module.css";
import { BUILTIN_THEMES, isBuiltinTheme, type BuiltinThemeId } from "@/platform/themes";
type Theme = "light" | "dark" | "dark-hc";
type Theme = BuiltinThemeId;
export const THEME_OPTIONS: SelectOption<Theme>[] = [
{ value: "light", label: "Islands Light" },
{ value: "dark", label: "Islands Dark" },
{ value: "dark-hc", label: "High Contrast" },
];
export const THEME_OPTIONS: SelectOption<Theme>[] = BUILTIN_THEMES.map((t) => ({ value: t.id, label: t.label }));
const DEFAULT_UI_FONT_SIZE = 13;
const DEFAULT_UI_FONT = "Inter";
@@ -52,7 +49,7 @@ interface AppearanceView {
/** The values Customize shows, from (possibly partial / legacy) settings. */
export function customizeView(settings: ReturnType<typeof useProject.getState>["settings"]): AppearanceView {
const a = (settings?.appearance ?? {}) as Record<string, unknown>;
const theme = a.theme === "dark" || a.theme === "dark-hc" ? a.theme : "light";
const theme = isBuiltinTheme(a.theme) ? a.theme : "light";
const custom = a.useCustomFont === true;
const size = typeof a.uiFontSize === "number" ? a.uiFontSize : DEFAULT_UI_FONT_SIZE;
return {