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();
});
});
+45 -21
Переглянути файл
@@ -18,6 +18,8 @@ export interface PluginsBackend {
runCommand(pluginId: string, commandId: string, arg: string | null): Promise<string | null>;
setActiveFile(path: string | null): Promise<void>;
setTheme(pluginId: string | null, themeId: string | null): Promise<ActiveTheme | null>;
/** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */
setThemeFrame(color: string | null): Promise<void>;
/** Folder picker; `null` when cancelled. */
pickFolder(): Promise<string | null>;
/** Subscribes to backend plugin events (this window only). */
@@ -40,6 +42,9 @@ export const tauriPluginsBackend: PluginsBackend = {
await commands.pluginSetActiveFile(path);
},
setTheme: (pluginId, themeId) => call(commands.pluginSetTheme(pluginId, themeId)),
setThemeFrame: async (color) => {
await call(commands.pluginSetThemeFrame(color));
},
pickFolder: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
@@ -71,21 +76,38 @@ function ensureSubscribed() {
// ---------------------------------------------------------------------------
// Browser simulation: the bundled sample plugins, with `hello` implemented in JS.
const SOLARIZED: ActiveTheme["theme"] = {
id: "solarized-dark",
name: "Solarized Dark",
kind: "dark",
tokens: {
"--bg-app": "#002b36",
"--bg-elev": "#03313c",
"--bg-popup": "#073642",
"--fg": "#93a1a1",
"--fg-strong": "#eee8d5",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
const SOLARIZED: 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",
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",
},
},
];
function samplePlugins(): PluginInfo[] {
const base = {
@@ -118,14 +140,14 @@ function samplePlugins(): PluginInfo[] {
},
{
...base,
id: "theme-solarized-dark",
name: "Solarized Dark Theme",
id: "theme-solarized",
name: "Solarized Themes",
version: "1.0.0",
description: "Ethan Schoonover's Solarized palette, dark variant.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized-dark",
description: "Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized",
status: "static",
hasCode: false,
themes: [{ id: "solarized-dark", name: "Solarized Dark", kind: "dark" }],
themes: SOLARIZED.map(({ id, name, kind }) => ({ id, name, kind })),
},
{
...base,
@@ -179,8 +201,9 @@ export function createMemoryPluginsBackend(
const activeTheme = (): ActiveTheme | null => {
if (!theme) return null;
const p = plugins.find((x) => x.id === theme?.pluginId);
if (!p?.enabled || theme.themeId !== SOLARIZED.id) return null;
return { pluginId: p.id, theme: SOLARIZED };
const t = SOLARIZED.find((x) => x.id === theme?.themeId);
if (!p?.enabled || !t) return null;
return { pluginId: p.id, theme: t };
};
return {
list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme() }),
@@ -237,6 +260,7 @@ export function createMemoryPluginsBackend(
emit({ type: "themeChanged", theme: t });
return t;
},
setThemeFrame: async () => {},
pickFolder: async () => null,
onEvent: (handler) => {
listeners.add(handler);
+60 -3
Переглянути файл
@@ -8,7 +8,7 @@ import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings";
import { activeEditor, useEditorRegistry } from "@/editor/api";
import { registerAction, type Action } from "@/platform/actions";
import { notifications } from "@/workbench/contributions";
import { applyThemeAttributes, currentThemeId, syncWindowBackground } from "@/workbench/theme";
import { applyThemeAttributes, currentThemeId, syncWindowBackground, windowBackgroundHex } from "@/workbench/theme";
import { pluginsBackend } from "./backend";
import { refreshPlugins, runPluginCommand, usePlugins } from "./store";
@@ -105,10 +105,67 @@ export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement =
if (had || theme) syncWindowBackground();
}
/** localStorage key read by public/boot.js: the selected theme's stylesheet, applied before the app loads. */
export const PLUGIN_THEME_CACHE_KEY = "openidea.pluginTheme";
export interface CachedPluginTheme {
/** `data-plugin-theme`: `<pluginId>/<themeId>`. */
id: string;
kind: "light" | "dark";
css: string;
}
function cachePluginTheme(theme: ActiveTheme | null) {
try {
if (!theme) {
localStorage.removeItem(PLUGIN_THEME_CACHE_KEY);
return;
}
const cached: CachedPluginTheme = {
id: `${theme.pluginId}/${theme.theme.id}`,
kind: theme.theme.kind === "light" ? "light" : "dark",
css: pluginThemeCss(theme.theme),
};
localStorage.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached));
} catch {
/* storage unavailable */
}
}
let reportedFrame: string | undefined;
/**
* Tells the backend the theme's resolved frame colour, which new windows open
* in. Selecting a theme clears it there, so it is sent once per selection.
*/
function reportThemeFrame(theme: ActiveTheme | null) {
const frame = theme ? windowBackgroundHex() : null;
if (!theme || !frame) {
reportedFrame = undefined;
return;
}
const key = `${theme.pluginId}/${theme.theme.id}|${frame}`;
if (key === reportedFrame) return;
reportedFrame = key;
void pluginsBackend()
.setThemeFrame(frame)
.catch(() => (reportedFrame = undefined));
}
/**
* Applies the selected plugin theme and keeps it for the next start-up. Until
* the first plugin list arrives, the theme public/boot.js restored stays.
*/
export function trackPluginTheme(): () => void {
applyPluginTheme(usePlugins.getState().activeTheme);
const sync = (s: ReturnType<typeof usePlugins.getState>) => {
if (!s.activeTheme && (!s.loaded || s.error)) return;
applyPluginTheme(s.activeTheme);
cachePluginTheme(s.activeTheme);
reportThemeFrame(s.activeTheme);
};
sync(usePlugins.getState());
const unsubscribe = usePlugins.subscribe((s, prev) => {
if (s.activeTheme !== prev.activeTheme) applyPluginTheme(s.activeTheme);
if (s.activeTheme !== prev.activeTheme || s.loaded !== prev.loaded) sync(s);
});
return () => {
unsubscribe();
+27 -1
Переглянути файл
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import type { Settings } from "@/platform/ipc";
import { commands, type Settings } from "@/platform/ipc";
const settingsSet = vi.fn();
const settingsGet = vi.fn();
@@ -282,3 +282,29 @@ describe("Settings dialog", () => {
expect(within(dialog).getByLabelText("UI font size")).toHaveValue("13");
});
});
describe("native Settings window background", () => {
it.each([
["a hex frame", "#26282c", "", "#26282c"],
["an hsl() frame", "hsl(220, 13%, 15%)", "rgb(33, 37, 43)", "#21252b"],
[
"a translucent color-mix() frame",
"color-mix(in srgb, #8040bf 80%, transparent)",
"color(srgb 0.5 0.25 0.75 / 0.8)",
"#8040bfcc",
],
])("sends %s as hex, the colour format the window command parses", async (_name, token, resolved, hex) => {
const native = vi.spyOn(commands, "windowOpenSettings").mockResolvedValue({ status: "ok", data: null });
const computed = vi
.spyOn(globalThis, "getComputedStyle")
.mockReturnValue({ getPropertyValue: () => token, color: resolved } as unknown as CSSStyleDeclaration);
try {
await executeAction("file.settings", "appearance");
expect(native).toHaveBeenCalledWith("appearance", false, hex);
expect(useSettingsDialog.getState().open).toBe(false);
} finally {
computed.mockRestore();
native.mockRestore();
}
});
});
+2 -2
Переглянути файл
@@ -17,6 +17,7 @@ import { call, commands, isTauri } from "@/platform/ipc";
import { isPopupOpen, lazyComponent } from "@/ui";
import { loadSettings, useProject } from "@/platform/project";
import { provideSettingsSearchIndex, registerOverlay } from "@/workbench/contributions";
import { windowBackgroundHex } from "@/workbench/theme";
import { LazySettingsDialog, loadBuiltinPages, unloadBuiltinPages } from "./dialog/LazySettingsDialog";
import {
closeSettings,
@@ -85,8 +86,7 @@ function settingsActions(open: (page?: string, newProjects?: boolean) => void):
async function showSettings(page?: string, newProjects = false) {
if (isTauri()) {
try {
const frame = getComputedStyle(document.documentElement).getPropertyValue("--bg-frame").trim();
await call(commands.windowOpenSettings(page ?? null, newProjects, frame || null));
await call(commands.windowOpenSettings(page ?? null, newProjects, windowBackgroundHex()));
return;
} catch (e) {
console.warn("settings window unavailable, using the dialog", e);
+5 -1
Переглянути файл
@@ -195,7 +195,11 @@ export function activate(): () => void {
terminalRuntime()?.applyThemeToAll();
terminalRuntime()?.applyThemeToEmbedded();
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "style"] });
// A 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"],
});
disposers.push(() => observer.disconnect());
}
+2 -29
Переглянути файл
@@ -7,36 +7,9 @@
import type { ITheme, Terminal } from "@xterm/xterm";
import { fontWeightOf } from "@/platform/editorSettings";
import { BUNDLED_MONO_FAMILY, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts";
import { parseColor, type Rgba } from "@/platform/cssColor";
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;
}
export { parseColor, type Rgba };
export function toHex([r, g, b]: Rgba): string {
const h = (n: number) =>
+61 -1
Переглянути файл
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderMarkdown } from "./render";
import { hasMermaid, renderMermaidIn, resetMermaidForTests } from "./mermaid";
import { hasMermaid, renderMermaidIn, resetMermaidForTests, subscribeTheme, themeKey } from "./mermaid";
const m = vi.hoisted(() => ({
loads: 0,
@@ -108,6 +108,66 @@ describe("mermaid in the markdown preview", () => {
expect(ids.size).toBe(2);
});
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";
const firstKey = themeKey();
await renderMermaidIn(preview(SEQ)).done;
root.dataset.pluginTheme = "theme-solarized/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();
delete root.dataset.pluginTheme;
}
});
it("passes colours a theme writes as hsl() or color-mix() to mermaid as RGB", async () => {
const values: Record<string, string> = {
"--bg-editor": "hsl(220, 13%, 18%)",
"--fg": "color-mix(in srgb, hsl(220, 14%, 71%) 90%, black)",
"--danger": "#e06c75",
};
const resolved: Record<string, string> = {
"--bg-editor": "rgb(40, 44, 52)",
"--fg": "color(srgb 0.6 0.62 0.66)",
};
let probe = "";
const computed = vi
.spyOn(globalThis, "getComputedStyle")
.mockImplementation(
(el) =>
(el === document.documentElement
? { getPropertyValue: (n: string) => values[n] ?? "" }
: {
color: resolved[(probe = (el as HTMLElement).style.color.slice(4, -1))] ?? "",
}) as unknown as CSSStyleDeclaration,
);
try {
await renderMermaidIn(preview(SEQ)).done;
expect(probe).not.toBe("");
expect(m.initialize).toHaveBeenCalledWith(
expect.objectContaining({
themeVariables: expect.objectContaining({
background: "rgb(40, 44, 52)",
textColor: "rgb(153, 158, 168)",
errorTextColor: "#e06c75",
}),
}),
);
expect(document.querySelector("span[hidden]")).toBeNull();
} finally {
computed.mockRestore();
}
});
it("skips superseded renders", async () => {
const root = preview(SEQ.replace("test", "stale"));
const pass = renderMermaidIn(root);
+13 -5
Переглянути файл
@@ -4,6 +4,7 @@
* turns placeholders into SVG. The library is imported on first use only.
*/
import type { Mermaid } from "mermaid";
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
export const MERMAID_SELECTOR = "[data-mermaid]";
const OUTPUT_CLASS = "mermaid-output";
@@ -29,27 +30,34 @@ function loadMermaid(): Promise<Mermaid> {
/** Theme identity: anything that changes the colours read below. */
export function themeKey(): string {
const root = document.documentElement;
return `${root.dataset.theme ?? ""}|${root.dataset.contrast ?? ""}|${root.style.getPropertyValue("--accent")}`;
const { theme = "", contrast = "", pluginTheme = "" } = root.dataset;
return `${theme}|${contrast}|${pluginTheme}|${root.style.getPropertyValue("--accent")}`;
}
/** Calls `cb` when the theme attributes or the accent on the root change. */
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
export function subscribeTheme(cb: () => void): () => void {
if (typeof MutationObserver === "undefined") return () => {};
const observer = new MutationObserver(cb);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "style"],
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme", "style"],
});
return () => observer.disconnect();
}
/** Diagram colours from the IDE tokens; values mermaid can't parse (e.g. `color-mix`) are skipped. */
/**
* Diagram colours from the IDE tokens. Mermaid parses only hex and `rgb()`, so
* tokens a theme writes as `hsl()`, `color-mix()` or `var()` are resolved to RGB.
*/
function themeVariables(): Record<string, string | boolean> {
const css = getComputedStyle(document.documentElement);
const root = document.documentElement;
const css = getComputedStyle(root);
const token = (...names: string[]) => {
for (const n of names) {
const v = css.getPropertyValue(n).trim();
if (/^(#[0-9a-f]{3,8}|rgba?\([^)]*\))$/i.test(v)) return v;
const resolved = v ? resolveTokenColor(n, root) : null;
if (resolved) return toRgb(resolved);
}
return undefined;
};