From fa003016ef6c8f00ce276592f27d126017d79980 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:46:46 +0800 Subject: [PATCH] feat(plugins): theme variables for html view frames, kept current without reload - Inject `data-theme` and stable `--openidea-*` colour and font variables (concrete values; the frame has no IDE stylesheet) into html view bodies. - Post theme changes to the loaded frame instead of rebuilding srcdoc, so a theme switch keeps the frame's state; re-send on load to close the race. - Expose `openidea.theme` / `openidea.onThemeChange` to frame scripts. - Move `subscribeTheme` / `themeKey` from the Markdown mermaid module to `src/platform/themeWatch.ts`, shared by both. - Document the variables in plugins/README.md. --- plugins/README.md | 17 +++ src/features/plugins/__tests__/views.test.tsx | 93 +++++++++++++++- src/features/plugins/views/PluginView.tsx | 36 +++++- src/features/plugins/views/frame.ts | 103 ++++++++++++++++-- .../viewers/markdown/MarkdownEditor.tsx | 3 +- src/features/viewers/markdown/mermaid.test.ts | 3 +- src/features/viewers/markdown/mermaid.ts | 21 +--- src/platform/themeWatch.ts | 25 +++++ 8 files changed, 265 insertions(+), 36 deletions(-) create mode 100644 src/platform/themeWatch.ts diff --git a/plugins/README.md b/plugins/README.md index c9d97c5f..4765e421 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -384,6 +384,23 @@ inline scripts and styles and `data:` images, no network, no IDE API — and talks to the plugin only through `openidea.postMessage(text)` / `openidea.onMessage(fn)` (`ViewEvent::Message`, `host::post_view_message`). +The frame follows the IDE theme without reloading: its root carries +`data-theme="light|dark"` and these CSS variables, with concrete colour +values (the body already uses the text colour, UI font and size): + +| Variable | Meaning | +| ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `--openidea-bg`, `--openidea-bg-elevated`, `--openidea-bg-hover`, `--openidea-bg-selected`, `--openidea-bg-input` | Surfaces (the frame itself is transparent) | +| `--openidea-fg`, `--openidea-fg-strong`, `--openidea-fg-muted` | Text | +| `--openidea-border`, `--openidea-focus-ring`, `--openidea-link` | Lines, focus, links | +| `--openidea-accent`, `--openidea-fg-on-accent` | Accent and text on it | +| `--openidea-danger`, `--openidea-warning`, `--openidea-success`, `--openidea-info` | Status colours | +| `--openidea-font-ui`, `--openidea-font-mono`, `--openidea-font-size` | Fonts (web fonts the IDE bundles are not available in the frame) | + +Scripts get the same as `openidea.theme` (`{ dark, vars }`) and +`openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only +ever added. + ### Settings ```toml diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index a78fce47..8d197d15 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -9,7 +9,7 @@ import { defineBuiltinUiPoints } from "../points"; import { clearUiContributions, syncUiContributions } from "../projection"; import { pluginMenuItems, withPluginSetting } from "../uiPoints"; import { matchesPattern } from "../views/customEditor"; -import { FRAME_CSP, frameDocument } from "../views/frame"; +import { FRAME_CSP, FRAME_TAG, frameDocument, frameTheme, type FrameTheme } from "../views/frame"; import { PluginView, treeNodes, wantedViewport } from "../views/PluginView"; import { bumpView } from "../views/viewStore"; @@ -56,7 +56,7 @@ describe("view helpers", () => { }); it("confines HTML bodies: CSP without network, a message bridge", () => { - const doc = frameDocument("

hi

", true); + const doc = frameDocument("

hi

", { dark: true, vars: {} }); expect(doc).toContain(`content="${FRAME_CSP}"`); expect(FRAME_CSP).toContain("default-src 'none'"); expect(FRAME_CSP).not.toMatch(/https?:|connect-src/); @@ -64,6 +64,71 @@ describe("view helpers", () => { expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(doc.indexOf("

hi

")); }); + it("reads the frame theme from the IDE tokens, resolved and sanitised", () => { + const root = document.documentElement; + const before = root.dataset.theme; + root.style.setProperty("--fg", "#112233"); + root.style.setProperty("--font-ui", "Inter, sans-serif"); + root.style.setProperty("--font-mono", "x}"); + try { + root.dataset.theme = "light"; + const light = frameTheme(); + expect(light.dark).toBe(false); + expect(light.vars["--openidea-fg"]).toBe("rgb(17, 34, 51)"); + expect(light.vars["--openidea-font-ui"]).toBe("Inter, sans-serif"); + expect(light.vars).not.toHaveProperty("--openidea-font-mono"); + root.dataset.theme = "dark"; + root.dataset.contrast = "high"; + expect(frameTheme().dark).toBe(true); + } finally { + for (const p of ["--fg", "--font-ui", "--font-mono"]) root.style.removeProperty(p); + delete root.dataset.contrast; + if (before === undefined) delete root.dataset.theme; + else root.dataset.theme = before; + } + }); + + it("puts the theme into the frame document without letting a value end the script", () => { + const doc = frameDocument("

hi

", { dark: true, vars: { "--openidea-fg": "" } }); + expect(doc).toContain(''); + expect(doc).not.toContain(""); + expect(doc).toContain("\\u003c/script>"); + }); + + it("applies posted themes inside the frame, replacing the previous variables", () => { + const doc = frameDocument("", { + dark: false, + vars: { "--openidea-fg": "rgb(1, 2, 3)", "--openidea-accent": "red" }, + }); + const script = /`, + ``, + ``, "", html, "", diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index 18b0895a..56531525 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -25,7 +25,8 @@ import { setShownLine } from "@/editor/api"; import { highlightInto } from "@/editor/lsp/markdown"; import { FloatingToolbar, selectableText } from "@/ui"; import { publishPreviewTop, useLiveMarkdown } from "./live"; -import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn, subscribeTheme, themeKey } from "./mermaid"; +import { subscribeTheme, themeKey } from "@/platform/themeWatch"; +import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn } from "./mermaid"; import { modeOf, useMarkdownModes } from "./modes"; import { MarkdownLayoutActions } from "./MarkdownLayoutActions"; import { diff --git a/src/features/viewers/markdown/mermaid.test.ts b/src/features/viewers/markdown/mermaid.test.ts index fe63bbb3..80b8b8bb 100644 --- a/src/features/viewers/markdown/mermaid.test.ts +++ b/src/features/viewers/markdown/mermaid.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderMarkdown } from "./render"; -import { hasMermaid, renderMermaidIn, resetMermaidForTests, subscribeTheme, themeKey } from "./mermaid"; +import { subscribeTheme, themeKey } from "@/platform/themeWatch"; +import { hasMermaid, renderMermaidIn, resetMermaidForTests } from "./mermaid"; const m = vi.hoisted(() => ({ loads: 0, diff --git a/src/features/viewers/markdown/mermaid.ts b/src/features/viewers/markdown/mermaid.ts index cce3cca2..fbd8bd0d 100644 --- a/src/features/viewers/markdown/mermaid.ts +++ b/src/features/viewers/markdown/mermaid.ts @@ -6,6 +6,7 @@ import { fontsLoaded } from "@/platform/fonts"; import type { Mermaid } from "mermaid"; import { resolveTokenColor, toRgb } from "@/platform/cssColor"; +import { themeKey } from "@/platform/themeWatch"; import { attachPanZoom } from "../panZoom"; export const MERMAID_SELECTOR = "[data-mermaid]"; @@ -29,26 +30,6 @@ function loadMermaid(): Promise { return loading; } -/** Theme identity: anything that changes the colours read below. */ -export function themeKey(): string { - const root = document.documentElement; - const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset; - const accent = root.style.getPropertyValue("--accent"); - const font = getComputedStyle(root).getPropertyValue("--font-ui"); - return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`; -} - -/** 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", "data-color-theme", "data-plugin-theme", "style"], - }); - return () => observer.disconnect(); -} - /** * 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. diff --git a/src/platform/themeWatch.ts b/src/platform/themeWatch.ts new file mode 100644 index 00000000..7cdfe530 --- /dev/null +++ b/src/platform/themeWatch.ts @@ -0,0 +1,25 @@ +/** + * Watching the active IDE theme: built-in theme, palette theme, plugin theme + * and accent all live as attributes / inline style on the root element. + */ + +/** Theme identity: changes whenever the colours the theme tokens resolve to may change. */ +export function themeKey(): string { + const root = document.documentElement; + const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset; + const accent = root.style.getPropertyValue("--accent"); + // The UI font (the custom one, or its fallback once loaded) is part of how a theme renders. + const font = getComputedStyle(root).getPropertyValue("--font-ui"); + return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`; +} + +/** 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", "data-color-theme", "data-plugin-theme", "style"], + }); + return () => observer.disconnect(); +}