@@ -367,13 +367,137 @@ export function bindSectionNavLinks(root) {
367367// Chart.js bubble charts per disharmony.
368368// ---------------------------------------------------------------------------
369369
370+ // Legend text colors mirroring --muted-color of each palette in
371+ // app/globals.css. The chart canvas is transparent, so legend text sits on
372+ // --bg-color (#ffffff light / #10161d dark); each value keeps >= 4.5:1
373+ // contrast against its own background (WCAG 1.4.3), which no single color
374+ // can achieve on both.
375+ const CHART_LEGEND_TEXT = Object . freeze ( {
376+ light : '#5c6b7a' , // 5.5:1 on #ffffff
377+ dark : '#9fb0c0' // 8.2:1 on #10161d
378+ } ) ;
379+
380+ /**
381+ * Returns 'light' or 'dark' for the active palette, mirroring the pure-CSS
382+ * selection in app/globals.css: the explicit radios win, the system radio
383+ * defers to prefers-color-scheme, and anything else is light.
384+ *
385+ * @returns {'light'|'dark' } Active palette name.
386+ */
387+ export function activeChartTheme ( ) {
388+ const doc = typeof document === 'undefined' ? null : document ;
389+ if ( doc ) {
390+ const dark = doc . getElementById ( 'rf-theme-dark' ) ;
391+ const light = doc . getElementById ( 'rf-theme-light' ) ;
392+ if ( dark && dark . checked ) return 'dark' ;
393+ if ( light && light . checked ) return 'light' ;
394+ }
395+ if ( typeof window !== 'undefined' && typeof window . matchMedia === 'function' ) {
396+ try {
397+ if ( window . matchMedia ( '(prefers-color-scheme: dark)' ) . matches ) return 'dark' ;
398+ } catch {
399+ // Media query evaluation unavailable: fall through to light.
400+ }
401+ }
402+ return 'light' ;
403+ }
404+
405+ /**
406+ * Legend text color for the currently active theme (>= 4.5:1 against the
407+ * theme's --bg-color). Resolved at draw time so a redraw picks up theme
408+ * switches.
409+ *
410+ * @returns {string } Hex color for legend item text.
411+ */
412+ export function resolveLegendTextColor ( ) {
413+ return CHART_LEGEND_TEXT [ activeChartTheme ( ) ] || CHART_LEGEND_TEXT . light ;
414+ }
415+
416+ // Live Chart.js instances by canvas, so theme changes can redraw the
417+ // canvas-drawn legend text (the palette switch itself is pure CSS).
418+ const bubbleCharts = new Map ( ) ;
419+
420+ /**
421+ * Redraws every live bubble chart so legend text re-resolves against the
422+ * newly active theme. Entries whose canvas left the DOM (report re-rendered
423+ * or torn down) are destroyed — releasing their Chart.js resources — and
424+ * pruned instead of redrawn.
425+ *
426+ * @returns {void }
427+ */
428+ function redrawChartsForTheme ( ) {
429+ for ( const [ canvas , chart ] of bubbleCharts ) {
430+ if ( ! canvas . isConnected ) {
431+ if ( chart && typeof chart . destroy === 'function' ) chart . destroy ( ) ;
432+ bubbleCharts . delete ( canvas ) ;
433+ continue ;
434+ }
435+ if ( chart && typeof chart . update === 'function' ) chart . update ( ) ;
436+ }
437+ }
438+
439+ /**
440+ * Destroys every live bubble chart and clears the registry. The report
441+ * component calls this before a payload change replaces the report markup
442+ * (container.innerHTML), so no Chart.js instance outlives its removed
443+ * canvas; enhanceReport then builds fresh charts for the new canvases.
444+ *
445+ * @returns {void }
446+ */
447+ export function destroyBubbleCharts ( ) {
448+ for ( const chart of bubbleCharts . values ( ) ) {
449+ if ( chart && typeof chart . destroy === 'function' ) chart . destroy ( ) ;
450+ }
451+ bubbleCharts . clear ( ) ;
452+ }
453+
454+ // The MediaQueryList for prefers-color-scheme, retained so the listener
455+ // (and the redraw callback it holds) stays reachable for the page lifetime.
456+ let systemThemeMediaQuery = null ;
457+
458+ /**
459+ * Binds one delegated listener redrawing charts on theme changes: the
460+ * rf-theme radios (components/theme-toggle.jsx) for explicit switches and
461+ * prefers-color-scheme for the system radio. Idempotent per document.
462+ *
463+ * @returns {void }
464+ */
465+ export function bindThemeChartRedraw ( ) {
466+ const doc = typeof document === 'undefined' ? null : document ;
467+ if ( ! doc || doc . documentElement . hasAttribute ( 'data-rf-theme-redraw-bound' ) ) return ;
468+ doc . documentElement . setAttribute ( 'data-rf-theme-redraw-bound' , '' ) ;
469+ doc . addEventListener ( 'change' , event => {
470+ const target = event . target ;
471+ if ( target && target . name === 'rf-theme' ) redrawChartsForTheme ( ) ;
472+ } ) ;
473+ if ( typeof window !== 'undefined' && typeof window . matchMedia === 'function' ) {
474+ try {
475+ // Retain the MediaQueryList and subscribe with the best listener API
476+ // it exposes: addEventListener, or the deprecated addListener on
477+ // engines that never grew the modern one.
478+ systemThemeMediaQuery = window . matchMedia ( '(prefers-color-scheme: dark)' ) ;
479+ if ( systemThemeMediaQuery && typeof systemThemeMediaQuery . addEventListener === 'function' ) {
480+ systemThemeMediaQuery . addEventListener ( 'change' , redrawChartsForTheme ) ;
481+ } else if ( systemThemeMediaQuery && typeof systemThemeMediaQuery . addListener === 'function' ) {
482+ systemThemeMediaQuery . addListener ( redrawChartsForTheme ) ;
483+ }
484+ } catch {
485+ // Older engines without MQL listeners: explicit radios still redraw.
486+ }
487+ }
488+ }
489+
370490export function initBubbleChart ( canvas , title , chartData ) {
371491 const ChartLib = window . Chart ;
372492 if ( ! ChartLib ) {
373493 console . warn ( 'Chart.js not loaded' ) ;
374494 return ;
375495 }
376- new ChartLib ( canvas . getContext ( '2d' ) , {
496+ // Chart.js refuses a canvas that still backs a live instance; drop the
497+ // previous chart (and its registry entry) before re-initializing.
498+ const previous = bubbleCharts . get ( canvas ) ;
499+ if ( previous && typeof previous . destroy === 'function' ) previous . destroy ( ) ;
500+ const chart = new ChartLib ( canvas . getContext ( '2d' ) , {
377501 type : 'bubble' ,
378502 data : {
379503 datasets : [ {
@@ -396,11 +520,20 @@ export function initBubbleChart(canvas, title, chartData) {
396520 padding : 15 ,
397521 boxWidth : 20 ,
398522 boxHeight : 20 ,
523+ // Chart.js renders each legend item's text from
524+ // item.fontColor (plugin.legend.js); labels.color only
525+ // feeds the DEFAULT generateLabels, which this chart
526+ // overrides below. The color is resolved per draw so
527+ // the theme-change redraw (bindThemeChartRedraw)
528+ // re-syncs it; each palette's value keeps >= 4.5:1
529+ // against its --bg-color (#ffffff light / #10161d
530+ // dark), which no single color can do for both.
399531 generateLabels : ( ) =>
400532 [ 'High Priority (1)' , 'Medium Priority' , 'Low Priority (max)' ] . map ( ( label , i ) => ( {
401533 text : label ,
402534 fillStyle : [ 'rgb(235, 64, 52)' , 'rgb(137, 119, 74)' , 'rgb(39, 174, 96)' ] [ i ] ,
403535 strokeStyle : [ 'rgb(235, 64, 52)' , 'rgb(137, 119, 74)' , 'rgb(39, 174, 96)' ] [ i ] ,
536+ fontColor : resolveLegendTextColor ( ) ,
404537 lineWidth : 1 ,
405538 hidden : false ,
406539 index : i
@@ -433,6 +566,7 @@ export function initBubbleChart(canvas, title, chartData) {
433566 }
434567 }
435568 } ) ;
569+ bubbleCharts . set ( canvas , chart ) ;
436570}
437571
438572export function initDisharmonyCharts ( disharmonies ) {
@@ -587,6 +721,9 @@ export async function enhanceReport(root, data) {
587721 if ( event . key === 'Escape' ) hidePopup ( ) ;
588722 } ) ;
589723 }
724+ // Theme switches are pure CSS, but chart legends are canvas-drawn: bind
725+ // their redraw once so the legend color keeps >= 4.5:1 in every palette.
726+ bindThemeChartRedraw ( ) ;
590727 bindPopupHandlers ( root ) ;
591728
592729 // Menu links: explicit scrolling because the report renders too late for
0 commit comments