@@ -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
853859export 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 - 9 a - f ] { 3 } | [ 0 - 9 a - 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+
856991function 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}
8611013window . odysseusInitMermaid = initMermaid ;
8621014initMermaid ( ) ;
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
0 commit comments