@@ -367,13 +367,109 @@ 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 pruned instead of redrawn.
424+ *
425+ * @returns {void }
426+ */
427+ function redrawChartsForTheme ( ) {
428+ for ( const [ canvas , chart ] of bubbleCharts ) {
429+ if ( ! canvas . isConnected ) {
430+ bubbleCharts . delete ( canvas ) ;
431+ continue ;
432+ }
433+ if ( chart && typeof chart . update === 'function' ) chart . update ( ) ;
434+ }
435+ }
436+
437+ /**
438+ * Binds one delegated listener redrawing charts on theme changes: the
439+ * rf-theme radios (components/theme-toggle.jsx) for explicit switches and
440+ * prefers-color-scheme for the system radio. Idempotent per document.
441+ *
442+ * @returns {void }
443+ */
444+ export function bindThemeChartRedraw ( ) {
445+ const doc = typeof document === 'undefined' ? null : document ;
446+ if ( ! doc || doc . documentElement . hasAttribute ( 'data-rf-theme-redraw-bound' ) ) return ;
447+ doc . documentElement . setAttribute ( 'data-rf-theme-redraw-bound' , '' ) ;
448+ doc . addEventListener ( 'change' , event => {
449+ const target = event . target ;
450+ if ( target && target . name === 'rf-theme' ) redrawChartsForTheme ( ) ;
451+ } ) ;
452+ if ( typeof window !== 'undefined' && typeof window . matchMedia === 'function' ) {
453+ try {
454+ window . matchMedia ( '(prefers-color-scheme: dark)' )
455+ . addEventListener ( 'change' , redrawChartsForTheme ) ;
456+ } catch {
457+ // Older engines without MQL listeners: explicit radios still redraw.
458+ }
459+ }
460+ }
461+
370462export function initBubbleChart ( canvas , title , chartData ) {
371463 const ChartLib = window . Chart ;
372464 if ( ! ChartLib ) {
373465 console . warn ( 'Chart.js not loaded' ) ;
374466 return ;
375467 }
376- new ChartLib ( canvas . getContext ( '2d' ) , {
468+ // Chart.js refuses a canvas that still backs a live instance; drop the
469+ // previous chart (and its registry entry) before re-initializing.
470+ const previous = bubbleCharts . get ( canvas ) ;
471+ if ( previous && typeof previous . destroy === 'function' ) previous . destroy ( ) ;
472+ const chart = new ChartLib ( canvas . getContext ( '2d' ) , {
377473 type : 'bubble' ,
378474 data : {
379475 datasets : [ {
@@ -399,17 +495,17 @@ export function initBubbleChart(canvas, title, chartData) {
399495 // Chart.js renders each legend item's text from
400496 // item.fontColor (plugin.legend.js); labels.color only
401497 // feeds the DEFAULT generateLabels, which this chart
402- // overrides below. The canvas is transparent, so the
403- // text sits on the page background (#ffffff light /
404- // #10161d dark): one gray for both, the balanced
405- // midpoint (~4.3:1 on each; no single gray can hit
406- // 4.5:1 on both) .
498+ // overrides below. The color is resolved per draw so
499+ // the theme-change redraw (bindThemeChartRedraw)
500+ // re-syncs it; each palette's value keeps >= 4.5:1
501+ // against its --bg-color (#ffffff light / #10161d
502+ // dark), which no single color can do for both.
407503 generateLabels : ( ) =>
408504 [ 'High Priority (1)' , 'Medium Priority' , 'Low Priority (max)' ] . map ( ( label , i ) => ( {
409505 text : label ,
410506 fillStyle : [ 'rgb(235, 64, 52)' , 'rgb(137, 119, 74)' , 'rgb(39, 174, 96)' ] [ i ] ,
411507 strokeStyle : [ 'rgb(235, 64, 52)' , 'rgb(137, 119, 74)' , 'rgb(39, 174, 96)' ] [ i ] ,
412- fontColor : '#7a7a7a' ,
508+ fontColor : resolveLegendTextColor ( ) ,
413509 lineWidth : 1 ,
414510 hidden : false ,
415511 index : i
@@ -442,6 +538,7 @@ export function initBubbleChart(canvas, title, chartData) {
442538 }
443539 }
444540 } ) ;
541+ bubbleCharts . set ( canvas , chart ) ;
445542}
446543
447544export function initDisharmonyCharts ( disharmonies ) {
@@ -596,6 +693,9 @@ export async function enhanceReport(root, data) {
596693 if ( event . key === 'Escape' ) hidePopup ( ) ;
597694 } ) ;
598695 }
696+ // Theme switches are pure CSS, but chart legends are canvas-drawn: bind
697+ // their redraw once so the legend color keeps >= 4.5:1 in every palette.
698+ bindThemeChartRedraw ( ) ;
599699 bindPopupHandlers ( root ) ;
600700
601701 // Menu links: explicit scrolling because the report renders too late for
0 commit comments