diff --git a/static/js/markdown.js b/static/js/markdown.js index 8735b83e7f..cd54fe83f8 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -828,6 +828,12 @@ export function renderMermaid(container) { const target = container || document; const pending = target.querySelectorAll('pre.mermaid:not([data-processed])'); if (pending.length === 0) return; + pending.forEach((node) => { + // Capture the diagram source before Mermaid replaces the
content with
+ // an SVG. Mermaid bakes the palette into that SVG at render time, so a later
+ // theme switch needs the original source to re-render with the new colors.
+ if (!_mermaidSource.has(node)) _mermaidSource.set(node, node.textContent);
+ });
try {
window.mermaid.run({ nodes: pending });
} catch (e) {
@@ -852,15 +858,197 @@ const markdownModule = {
export default markdownModule;
-// Mermaid is loaded async so it cannot delay the app shell.
+// ── Mermaid diagram theming ────────────────────────────────────────────────
+// Mermaid is loaded async (see index.html) so it cannot delay the app shell.
+// It renders each diagram to an SVG with the palette baked in at render time,
+// so a plain CSS cascade can't retheme an already-drawn diagram. Instead of the
+// canned 'dark' theme we drive Mermaid's themeable 'base' theme from the app's
+// live CSS variables — the same tokens that colour the rest of the UI — so
+// diagrams follow whichever preset (or custom palette) is active, and a live
+// theme switch re-renders them (see the listener at the bottom).
+
+// Read a CSS custom property off :root, trimmed, with a fallback.
+function _cssVar(name, fallback) {
+ try {
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name);
+ return (v && v.trim()) || fallback;
+ } catch (_) {
+ return fallback;
+ }
+}
+
+// Perceived luminance (0..1) of a hex or rgb() colour. Used to tell Mermaid
+// whether the active palette is light or dark so it derives sensible shades for
+// anything the explicit variables below don't cover.
+function _luminance01(color) {
+ const c = String(color || '').trim();
+ let r, g, b;
+ const hex = c.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
+ if (hex) {
+ let h = hex[1];
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
+ r = parseInt(h.slice(0, 2), 16);
+ g = parseInt(h.slice(2, 4), 16);
+ b = parseInt(h.slice(4, 6), 16);
+ } else {
+ const m = c.match(/[\d.]+/g);
+ if (!m || m.length < 3) return 0;
+ [r, g, b] = m.map(Number);
+ }
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
+}
+function _isLightColor(color) {
+ return _luminance01(color) >= 0.5;
+}
+
+// Read the palette Mermaid should follow from the app's live theme tokens.
+function _readMermaidPalette() {
+ return {
+ bg: _cssVar('--bg', '#282c34'),
+ panel: _cssVar('--panel', '#111111'),
+ fg: _cssVar('--fg', '#9cdef2'),
+ border: _cssVar('--border', '#355a66'),
+ accent: _cssVar('--accent', '') || _cssVar('--red', '#e06c75'),
+ font: _cssVar('--font-family', "'Fira Code', ui-monospace, monospace"),
+ };
+}
+
+// Map an app palette onto Mermaid's 'base' theme variables. Pure (colours in,
+// object out) so the mapping is unit-testable without a DOM. Exported for the
+// node-driven test suite; not part of the public markdown API.
+export function _mermaidThemeVariables(palette) {
+ const { bg, panel, fg, border, accent, font } = palette;
+ const darkMode = !_isLightColor(bg);
+ return {
+ darkMode,
+ fontFamily: font,
+ background: bg,
+ // Node fills use the panel colour; clusters and alternate fills use the
+ // page background so grouped and plain nodes stay distinct within the palette.
+ primaryColor: panel,
+ mainBkg: panel,
+ secondaryColor: bg,
+ tertiaryColor: bg,
+ // All text tracks the foreground colour.
+ primaryTextColor: fg,
+ secondaryTextColor: fg,
+ tertiaryTextColor: fg,
+ textColor: fg,
+ nodeTextColor: fg,
+ titleColor: fg,
+ // Edges and lines use the border colour; node outlines pick up the accent.
+ lineColor: border,
+ primaryBorderColor: accent,
+ secondaryBorderColor: border,
+ tertiaryBorderColor: border,
+ nodeBorder: accent,
+ clusterBkg: bg,
+ clusterBorder: border,
+ edgeLabelBackground: panel,
+ // Sequence / flowchart specifics.
+ actorBkg: panel,
+ actorBorder: accent,
+ actorTextColor: fg,
+ actorLineColor: border,
+ signalColor: fg,
+ signalTextColor: fg,
+ labelBoxBkgColor: panel,
+ labelBoxBorderColor: border,
+ labelTextColor: fg,
+ loopTextColor: fg,
+ noteBkgColor: panel,
+ noteBorderColor: accent,
+ noteTextColor: fg,
+ };
+}
+
+// Full Mermaid config for the active palette. securityLevel stays 'loose' to
+// preserve existing behaviour — only the theming changes here.
+function _mermaidThemeConfig() {
+ const palette = _readMermaidPalette();
+ return {
+ startOnLoad: false,
+ securityLevel: 'loose',
+ theme: 'base',
+ fontFamily: palette.font,
+ themeVariables: _mermaidThemeVariables(palette),
+ };
+}
+
+// Signature of the palette Mermaid was last initialised with. Because colours
+// are baked into the SVG at render time, a palette change requires a fresh
+// initialize() (and re-render) rather than a CSS update.
+let _mermaidThemeKey = null;
+function _themeKey() {
+ const p = _readMermaidPalette();
+ return [p.bg, p.panel, p.fg, p.border, p.accent, p.font].join('|');
+}
+
+// Diagram source keyed by its node, captured in renderMermaid() before
+// Mermaid overwrites the node with an SVG, so we can re-render on theme change.
+const _mermaidSource = new WeakMap();
+
function initMermaid() {
- if (!window.mermaid || window.__odysseusMermaidReady) return;
- window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
+ if (!window.mermaid) return;
+ const key = _themeKey();
+ if (_mermaidThemeKey === key) return;
+ try {
+ window.mermaid.initialize(_mermaidThemeConfig());
+ } catch (err) {
+ // A theme variable Mermaid's colour parser rejects must never abort every
+ // diagram. Fall back to the plain base theme (still on the app font).
+ console.warn('Mermaid theme init failed, using safe defaults:', err);
+ try {
+ window.mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: 'loose',
+ theme: 'base',
+ fontFamily: _cssVar('--font-family', "'Fira Code', ui-monospace, monospace"),
+ });
+ } catch (_) { /* leave Mermaid at its own defaults */ }
+ }
+ _mermaidThemeKey = key;
window.__odysseusMermaidReady = true;
}
window.odysseusInitMermaid = initMermaid;
initMermaid();
+// Re-render every already-drawn diagram with the current palette. Fired
+// (debounced) on a live theme switch; a no-op when nothing is on screen.
+function _reRenderMermaidForTheme() {
+ if (!window.mermaid) return;
+ const rendered = document.querySelectorAll('pre.mermaid[data-processed]');
+ if (rendered.length === 0) return;
+ _mermaidThemeKey = null; // force re-initialise with the new palette
+ initMermaid();
+ const nodes = [];
+ rendered.forEach((node) => {
+ const src = _mermaidSource.get(node);
+ if (!src) return;
+ node.removeAttribute('data-processed');
+ node.textContent = src; // restore source so mermaid.run re-reads it
+ nodes.push(node);
+ });
+ if (nodes.length === 0) return;
+ try {
+ window.mermaid.run({ nodes });
+ } catch (e) {
+ console.warn('Mermaid re-render error:', e);
+ }
+}
+
+// theme.js dispatches this on every palette apply, including the live-preview
+// colour sliders — debounce so a drag re-renders once, and only touch diagrams
+// that already exist.
+let _mermaidThemeTimer = null;
+document.addEventListener('odysseus-theme-changed', () => {
+ if (_mermaidThemeTimer) clearTimeout(_mermaidThemeTimer);
+ _mermaidThemeTimer = setTimeout(() => {
+ _mermaidThemeTimer = null;
+ _reRenderMermaidForTheme();
+ }, 200);
+});
+
// Persist which thinking sections were expanded across page refreshes.
// IDs are render-generated (Date.now-based) so we key by a stable hash of
// the inner text content instead — same content reproduces the same hash on
diff --git a/static/js/theme.js b/static/js/theme.js
index 09b13f2b76..4947571623 100644
--- a/static/js/theme.js
+++ b/static/js/theme.js
@@ -289,6 +289,16 @@ export function applyColors(colors) {
// Update favicon to match theme accent color
_updateFavicon(colors.red || '#e06c75');
+
+ // Notify theme-reactive renderers that bake colors into their output and so
+ // cannot follow the palette through CSS alone. Mermaid diagrams draw an SVG
+ // with fixed fills at render time; the markdown module listens for this event
+ // and (debounced) re-renders any on-screen diagram with the new palette.
+ // Fire-and-forget — a missing CustomEvent constructor must never break a
+ // theme switch.
+ try {
+ document.dispatchEvent(new CustomEvent('odysseus-theme-changed', { detail: colors }));
+ } catch (_) { /* older engines: non-fatal */ }
}
// Per-route SVG shape registry — kept in sync with the inline favicon
diff --git a/tests/test_mermaid_theming_js.py b/tests/test_mermaid_theming_js.py
new file mode 100644
index 0000000000..89d7bcd5bc
--- /dev/null
+++ b/tests/test_mermaid_theming_js.py
@@ -0,0 +1,199 @@
+"""Regression coverage for Mermaid diagram theming in the browser renderer.
+
+Mermaid renders each diagram to an SVG with the palette baked in at render time,
+so the diagram cannot follow the app theme through CSS alone. `static/js/markdown.js`
+drives Mermaid's themeable `base` theme from the app's live CSS variables and
+re-renders on-screen diagrams when the theme changes. These tests exercise that
+logic under `node`, mirroring tests/test_markdown_rendering_js.py.
+"""
+
+import json
+import shutil
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+_REPO = Path(__file__).resolve().parent.parent
+_HAS_NODE = shutil.which("node") is not None
+
+
+@pytest.fixture(scope="module")
+def node_available():
+ if not _HAS_NODE:
+ pytest.skip("node binary not on PATH")
+
+
+# JS that loads static/js/markdown.js as an in-memory ES module, stripping the
+# browser-only imports the same way tests/test_markdown_rendering_js.py does.
+_LOAD_MODULE = r"""
+ let source = fs.readFileSync('./static/js/markdown.js', 'utf8');
+ source = source.replace(/import uiModule from ['"]\.\/ui\.js['"];/, '');
+ source = source.replace(
+ /import \{ splitTableRow \} from ['"]\.\/markdown\/tableRow\.js['"];/,
+ `function splitTableRow(row) {
+ return (row || '').replace(/^\\s*\\|/, '').replace(/\\|\\s*$/, '').split('|').map(c => c.trim());
+ }`
+ );
+ const emojiSource = fs.readFileSync('./static/js/emojiShortcodes.js', 'utf8')
+ .replace(/^export default .*$/m, '')
+ .replace(/export const /g, 'const ')
+ .replace(/export function /g, 'function ');
+ source = source.replace(
+ /import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from ['"]\.\/emojiShortcodes\.js['"];/,
+ () => emojiSource
+ );
+ source = source.replace(
+ /var escapeHtml = uiModule\.esc;/,
+ `var escapeHtml = (value) => String(value ?? '');`
+ );
+ const moduleUrl = 'data:text/javascript;base64,' + Buffer.from(source).toString('base64');
+ const mod = await import(moduleUrl);
+"""
+
+
+def _run_node(script: str) -> dict:
+ result = subprocess.run(
+ ["node", "--input-type=module", "-e", textwrap.dedent(script)],
+ cwd=_REPO,
+ capture_output=True,
+ timeout=15,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise AssertionError(f"node failed:\nSTDERR:\n{result.stderr}\nSTDOUT:\n{result.stdout}")
+ return json.loads(result.stdout.splitlines()[-1])
+
+
+# Generic English palettes — a dark one and a light one. No app-specific data.
+_DARK = {
+ "bg": "#282c34", "panel": "#111111", "fg": "#9cdef2",
+ "border": "#355a66", "accent": "#e06c75", "font": "'Fira Code', monospace",
+}
+_LIGHT = {
+ "bg": "#f0ebe3", "panel": "#faf6f0", "fg": "#5a5248",
+ "border": "#d4cdc2", "accent": "#c47d5a", "font": "'Fira Code', monospace",
+}
+
+
+def test_theme_variables_map_palette_to_mermaid(node_available):
+ """_mermaidThemeVariables maps each app colour onto the matching Mermaid
+ theme variable, and derives darkMode from the background luminance."""
+ script = (
+ "import fs from 'node:fs';\n"
+ "globalThis.window = { location: { origin: 'http://localhost' } };\n"
+ "globalThis.document = { readyState: 'loading', addEventListener() {},\n"
+ " createElement() { return { content: { querySelectorAll() { return []; } },\n"
+ " set innerHTML(v) {}, get innerHTML() { return ''; } }; } };\n"
+ "globalThis.MutationObserver = class { observe() {} };\n"
+ + _LOAD_MODULE
+ + "const dark = mod._mermaidThemeVariables(" + json.dumps(_DARK) + ");\n"
+ + "const light = mod._mermaidThemeVariables(" + json.dumps(_LIGHT) + ");\n"
+ + "console.log(JSON.stringify({ dark, light }));\n"
+ )
+ out = _run_node(script)
+ dark, light = out["dark"], out["light"]
+
+ # Dark palette maps straight through to the matching Mermaid variables.
+ assert dark["background"] == _DARK["bg"]
+ assert dark["primaryColor"] == _DARK["panel"]
+ assert dark["mainBkg"] == _DARK["panel"]
+ assert dark["primaryTextColor"] == _DARK["fg"]
+ assert dark["textColor"] == _DARK["fg"]
+ assert dark["lineColor"] == _DARK["border"]
+ assert dark["nodeBorder"] == _DARK["accent"]
+ assert dark["primaryBorderColor"] == _DARK["accent"]
+ assert dark["fontFamily"] == _DARK["font"]
+ assert dark["darkMode"] is True
+
+ # Light palette flips darkMode and follows the light colours instead.
+ assert light["background"] == _LIGHT["bg"]
+ assert light["primaryColor"] == _LIGHT["panel"]
+ assert light["primaryTextColor"] == _LIGHT["fg"]
+ assert light["lineColor"] == _LIGHT["border"]
+ assert light["nodeBorder"] == _LIGHT["accent"]
+ assert light["darkMode"] is False
+
+ # The two palettes must actually differ, or the diagram wouldn't retheme.
+ assert dark["background"] != light["background"]
+ assert dark["primaryTextColor"] != light["primaryTextColor"]
+
+
+def test_theme_change_dispatch_rerenders_mermaid(node_available):
+ """Dispatching `odysseus-theme-changed` re-initialises Mermaid with the new
+ palette and re-renders every already-drawn diagram from its saved source."""
+ script = (
+ "import fs from 'node:fs';\n"
+ # Collapse the debounce timer so the re-render runs synchronously.
+ "globalThis.setTimeout = (fn) => { fn(); return 0; };\n"
+ "globalThis.clearTimeout = () => {};\n"
+ # Live CSS variables, swapped from dark to light between renders.
+ "let CSSVARS = {\n"
+ " '--bg': '#282c34', '--panel': '#111111', '--fg': '#9cdef2',\n"
+ " '--border': '#355a66', '--red': '#e06c75', '--font-family': \"'Fira Code', monospace\"\n"
+ "};\n"
+ "globalThis.getComputedStyle = () => ({ getPropertyValue(n) { return CSSVARS[n] || ''; } });\n"
+ "globalThis.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };\n"
+ # One fake node with an attribute map.
+ "function makeNode(text) {\n"
+ " const attrs = new Map();\n"
+ " return { textContent: text,\n"
+ " getAttribute(n) { return attrs.has(n) ? attrs.get(n) : null; },\n"
+ " setAttribute(n, v) { attrs.set(n, String(v)); },\n"
+ " removeAttribute(n) { attrs.delete(n); },\n"
+ " hasAttribute(n) { return attrs.has(n); },\n"
+ " _processed() { return attrs.has('data-processed'); } };\n"
+ "}\n"
+ "const NODE = makeNode('graph TD; Start-->Process');\n"
+ "const LISTENERS = {};\n"
+ "globalThis.document = {\n"
+ " documentElement: {}, readyState: 'loading',\n"
+ " addEventListener(type, fn) { (LISTENERS[type] = LISTENERS[type] || []).push(fn); },\n"
+ " dispatchEvent(evt) { (LISTENERS[evt.type] || []).forEach(fn => fn(evt)); return true; },\n"
+ " createElement() { return { content: { querySelectorAll() { return []; } },\n"
+ " set innerHTML(v) {}, get innerHTML() { return ''; } }; },\n"
+ " querySelectorAll(sel) {\n"
+ " const wantProcessed = sel.includes('[data-processed]') && !sel.includes(':not');\n"
+ " return [NODE].filter(n => wantProcessed ? n._processed() : !n._processed());\n"
+ " } };\n"
+ "globalThis.MutationObserver = class { observe() {} };\n"
+ # Fake Mermaid global recording every initialize() / run() call.
+ "const initCalls = []; const runCalls = [];\n"
+ "globalThis.window = { location: { origin: 'http://localhost' },\n"
+ " mermaid: {\n"
+ " initialize(cfg) { initCalls.push(cfg); },\n"
+ " run({ nodes }) { runCalls.push(nodes.map(n => n.textContent));\n"
+ " nodes.forEach(n => n.setAttribute('data-processed', 'true')); } } };\n"
+ + _LOAD_MODULE
+ # First render on the dark palette (captures source, marks processed).
+ + "mod.renderMermaid();\n"
+ # Switch the live palette to light, then fire the theme-change event.
+ + "CSSVARS = {\n"
+ " '--bg': '#f0ebe3', '--panel': '#faf6f0', '--fg': '#5a5248',\n"
+ " '--border': '#d4cdc2', '--red': '#c47d5a', '--font-family': \"'Fira Code', monospace\"\n"
+ "};\n"
+ "document.dispatchEvent(new CustomEvent('odysseus-theme-changed', { detail: {} }));\n"
+ "const last = initCalls[initCalls.length - 1];\n"
+ "console.log(JSON.stringify({\n"
+ " initCount: initCalls.length,\n"
+ " firstBg: initCalls[0] && initCalls[0].themeVariables && initCalls[0].themeVariables.background,\n"
+ " lastBg: last && last.themeVariables && last.themeVariables.background,\n"
+ " lastTheme: last && last.theme,\n"
+ " lastDarkMode: last && last.themeVariables && last.themeVariables.darkMode,\n"
+ " runCount: runCalls.length,\n"
+ " lastRunSource: runCalls[runCalls.length - 1]\n"
+ "}));\n"
+ )
+ out = _run_node(script)
+
+ # Mermaid was initialised once for the dark palette (at load / first render)
+ # and re-initialised for the light palette when the theme changed.
+ assert out["initCount"] >= 2
+ assert out["firstBg"] == _DARK["bg"]
+ assert out["lastBg"] == _LIGHT["bg"]
+ assert out["lastTheme"] == "base"
+ assert out["lastDarkMode"] is False
+ # The diagram was rendered once, then re-rendered from its preserved source.
+ assert out["runCount"] == 2
+ assert out["lastRunSource"] == ["graph TD; Start-->Process"]