From 6bc144cae04597363cd1d5f53f02fab779454022 Mon Sep 17 00:00:00 2001 From: BrunooMoniz Date: Sat, 18 Jul 2026 03:19:52 +0000 Subject: [PATCH] fix(chat): theme mermaid diagrams to the active palette and re-render on theme change initMermaid() initialised Mermaid once with a hard-coded theme: 'dark', so diagrams never followed the app palette (dark-on-light on light themes) and did not retheme on a theme switch. Build Mermaid's themeable 'base' theme from the app's existing CSS variables (--bg/--panel/--fg/--border/--accent|--red/ --font-family), re-initialise when the palette changes, and re-render on-screen diagrams when theme.js dispatches an odysseus-theme-changed event. Stays on the CDN-loaded Mermaid; no new colours, fonts, or components. --- static/js/markdown.js | 194 +++++++++++++++++++++++++++++- static/js/theme.js | 10 ++ tests/test_mermaid_theming_js.py | 199 +++++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 tests/test_mermaid_theming_js.py 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"]