Skip to content

Commit 6bc144c

Browse files
committed
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.
1 parent 28d27ee commit 6bc144c

3 files changed

Lines changed: 400 additions & 3 deletions

File tree

static/js/markdown.js

Lines changed: 191 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,12 @@ export function renderMermaid(container) {
828828
const target = container || document;
829829
const pending = target.querySelectorAll('pre.mermaid:not([data-processed])');
830830
if (pending.length === 0) return;
831+
pending.forEach((node) => {
832+
// Capture the diagram source before Mermaid replaces the <pre> content with
833+
// an SVG. Mermaid bakes the palette into that SVG at render time, so a later
834+
// theme switch needs the original source to re-render with the new colors.
835+
if (!_mermaidSource.has(node)) _mermaidSource.set(node, node.textContent);
836+
});
831837
try {
832838
window.mermaid.run({ nodes: pending });
833839
} catch (e) {
@@ -852,15 +858,197 @@ const markdownModule = {
852858

853859
export default markdownModule;
854860

855-
// Mermaid is loaded async so it cannot delay the app shell.
861+
// ── Mermaid diagram theming ────────────────────────────────────────────────
862+
// Mermaid is loaded async (see index.html) so it cannot delay the app shell.
863+
// It renders each diagram to an SVG with the palette baked in at render time,
864+
// so a plain CSS cascade can't retheme an already-drawn diagram. Instead of the
865+
// canned 'dark' theme we drive Mermaid's themeable 'base' theme from the app's
866+
// live CSS variables — the same tokens that colour the rest of the UI — so
867+
// diagrams follow whichever preset (or custom palette) is active, and a live
868+
// theme switch re-renders them (see the listener at the bottom).
869+
870+
// Read a CSS custom property off :root, trimmed, with a fallback.
871+
function _cssVar(name, fallback) {
872+
try {
873+
const v = getComputedStyle(document.documentElement).getPropertyValue(name);
874+
return (v && v.trim()) || fallback;
875+
} catch (_) {
876+
return fallback;
877+
}
878+
}
879+
880+
// Perceived luminance (0..1) of a hex or rgb() colour. Used to tell Mermaid
881+
// whether the active palette is light or dark so it derives sensible shades for
882+
// anything the explicit variables below don't cover.
883+
function _luminance01(color) {
884+
const c = String(color || '').trim();
885+
let r, g, b;
886+
const hex = c.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
887+
if (hex) {
888+
let h = hex[1];
889+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
890+
r = parseInt(h.slice(0, 2), 16);
891+
g = parseInt(h.slice(2, 4), 16);
892+
b = parseInt(h.slice(4, 6), 16);
893+
} else {
894+
const m = c.match(/[\d.]+/g);
895+
if (!m || m.length < 3) return 0;
896+
[r, g, b] = m.map(Number);
897+
}
898+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
899+
}
900+
function _isLightColor(color) {
901+
return _luminance01(color) >= 0.5;
902+
}
903+
904+
// Read the palette Mermaid should follow from the app's live theme tokens.
905+
function _readMermaidPalette() {
906+
return {
907+
bg: _cssVar('--bg', '#282c34'),
908+
panel: _cssVar('--panel', '#111111'),
909+
fg: _cssVar('--fg', '#9cdef2'),
910+
border: _cssVar('--border', '#355a66'),
911+
accent: _cssVar('--accent', '') || _cssVar('--red', '#e06c75'),
912+
font: _cssVar('--font-family', "'Fira Code', ui-monospace, monospace"),
913+
};
914+
}
915+
916+
// Map an app palette onto Mermaid's 'base' theme variables. Pure (colours in,
917+
// object out) so the mapping is unit-testable without a DOM. Exported for the
918+
// node-driven test suite; not part of the public markdown API.
919+
export function _mermaidThemeVariables(palette) {
920+
const { bg, panel, fg, border, accent, font } = palette;
921+
const darkMode = !_isLightColor(bg);
922+
return {
923+
darkMode,
924+
fontFamily: font,
925+
background: bg,
926+
// Node fills use the panel colour; clusters and alternate fills use the
927+
// page background so grouped and plain nodes stay distinct within the palette.
928+
primaryColor: panel,
929+
mainBkg: panel,
930+
secondaryColor: bg,
931+
tertiaryColor: bg,
932+
// All text tracks the foreground colour.
933+
primaryTextColor: fg,
934+
secondaryTextColor: fg,
935+
tertiaryTextColor: fg,
936+
textColor: fg,
937+
nodeTextColor: fg,
938+
titleColor: fg,
939+
// Edges and lines use the border colour; node outlines pick up the accent.
940+
lineColor: border,
941+
primaryBorderColor: accent,
942+
secondaryBorderColor: border,
943+
tertiaryBorderColor: border,
944+
nodeBorder: accent,
945+
clusterBkg: bg,
946+
clusterBorder: border,
947+
edgeLabelBackground: panel,
948+
// Sequence / flowchart specifics.
949+
actorBkg: panel,
950+
actorBorder: accent,
951+
actorTextColor: fg,
952+
actorLineColor: border,
953+
signalColor: fg,
954+
signalTextColor: fg,
955+
labelBoxBkgColor: panel,
956+
labelBoxBorderColor: border,
957+
labelTextColor: fg,
958+
loopTextColor: fg,
959+
noteBkgColor: panel,
960+
noteBorderColor: accent,
961+
noteTextColor: fg,
962+
};
963+
}
964+
965+
// Full Mermaid config for the active palette. securityLevel stays 'loose' to
966+
// preserve existing behaviour — only the theming changes here.
967+
function _mermaidThemeConfig() {
968+
const palette = _readMermaidPalette();
969+
return {
970+
startOnLoad: false,
971+
securityLevel: 'loose',
972+
theme: 'base',
973+
fontFamily: palette.font,
974+
themeVariables: _mermaidThemeVariables(palette),
975+
};
976+
}
977+
978+
// Signature of the palette Mermaid was last initialised with. Because colours
979+
// are baked into the SVG at render time, a palette change requires a fresh
980+
// initialize() (and re-render) rather than a CSS update.
981+
let _mermaidThemeKey = null;
982+
function _themeKey() {
983+
const p = _readMermaidPalette();
984+
return [p.bg, p.panel, p.fg, p.border, p.accent, p.font].join('|');
985+
}
986+
987+
// Diagram source keyed by its <pre> node, captured in renderMermaid() before
988+
// Mermaid overwrites the node with an SVG, so we can re-render on theme change.
989+
const _mermaidSource = new WeakMap();
990+
856991
function initMermaid() {
857-
if (!window.mermaid || window.__odysseusMermaidReady) return;
858-
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
992+
if (!window.mermaid) return;
993+
const key = _themeKey();
994+
if (_mermaidThemeKey === key) return;
995+
try {
996+
window.mermaid.initialize(_mermaidThemeConfig());
997+
} catch (err) {
998+
// A theme variable Mermaid's colour parser rejects must never abort every
999+
// diagram. Fall back to the plain base theme (still on the app font).
1000+
console.warn('Mermaid theme init failed, using safe defaults:', err);
1001+
try {
1002+
window.mermaid.initialize({
1003+
startOnLoad: false,
1004+
securityLevel: 'loose',
1005+
theme: 'base',
1006+
fontFamily: _cssVar('--font-family', "'Fira Code', ui-monospace, monospace"),
1007+
});
1008+
} catch (_) { /* leave Mermaid at its own defaults */ }
1009+
}
1010+
_mermaidThemeKey = key;
8591011
window.__odysseusMermaidReady = true;
8601012
}
8611013
window.odysseusInitMermaid = initMermaid;
8621014
initMermaid();
8631015

1016+
// Re-render every already-drawn diagram with the current palette. Fired
1017+
// (debounced) on a live theme switch; a no-op when nothing is on screen.
1018+
function _reRenderMermaidForTheme() {
1019+
if (!window.mermaid) return;
1020+
const rendered = document.querySelectorAll('pre.mermaid[data-processed]');
1021+
if (rendered.length === 0) return;
1022+
_mermaidThemeKey = null; // force re-initialise with the new palette
1023+
initMermaid();
1024+
const nodes = [];
1025+
rendered.forEach((node) => {
1026+
const src = _mermaidSource.get(node);
1027+
if (!src) return;
1028+
node.removeAttribute('data-processed');
1029+
node.textContent = src; // restore source so mermaid.run re-reads it
1030+
nodes.push(node);
1031+
});
1032+
if (nodes.length === 0) return;
1033+
try {
1034+
window.mermaid.run({ nodes });
1035+
} catch (e) {
1036+
console.warn('Mermaid re-render error:', e);
1037+
}
1038+
}
1039+
1040+
// theme.js dispatches this on every palette apply, including the live-preview
1041+
// colour sliders — debounce so a drag re-renders once, and only touch diagrams
1042+
// that already exist.
1043+
let _mermaidThemeTimer = null;
1044+
document.addEventListener('odysseus-theme-changed', () => {
1045+
if (_mermaidThemeTimer) clearTimeout(_mermaidThemeTimer);
1046+
_mermaidThemeTimer = setTimeout(() => {
1047+
_mermaidThemeTimer = null;
1048+
_reRenderMermaidForTheme();
1049+
}, 200);
1050+
});
1051+
8641052
// Persist which thinking sections were expanded across page refreshes.
8651053
// IDs are render-generated (Date.now-based) so we key by a stable hash of
8661054
// the inner text content instead — same content reproduces the same hash on

static/js/theme.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,16 @@ export function applyColors(colors) {
289289

290290
// Update favicon to match theme accent color
291291
_updateFavicon(colors.red || '#e06c75');
292+
293+
// Notify theme-reactive renderers that bake colors into their output and so
294+
// cannot follow the palette through CSS alone. Mermaid diagrams draw an SVG
295+
// with fixed fills at render time; the markdown module listens for this event
296+
// and (debounced) re-renders any on-screen diagram with the new palette.
297+
// Fire-and-forget — a missing CustomEvent constructor must never break a
298+
// theme switch.
299+
try {
300+
document.dispatchEvent(new CustomEvent('odysseus-theme-changed', { detail: colors }));
301+
} catch (_) { /* older engines: non-fatal */ }
292302
}
293303

294304
// Per-route SVG shape registry — kept in sync with the inline favicon

0 commit comments

Comments
 (0)