Skip to content

Commit 60ace64

Browse files
Updated per CodeRabbit review
1 parent 8f3be78 commit 60ace64

3 files changed

Lines changed: 311 additions & 37 deletions

File tree

‎AGENTS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket).
1818
of the breadcrumbs' left-edge alignment) — palettes selected in
1919
`app/globals.css` via `:root:has(#rf-theme-…:checked)` +
2020
`prefers-color-scheme`; an inline pre-paint script in `app/layout.jsx`
21-
only restores/persists the choice (CSP-hashed by the build). Guards:
21+
only restores/persists the choice (CSP-hashed by the build). Chart
22+
legends are canvas-drawn, so they can't follow the CSS switch:
23+
`resolveLegendTextColor`/`bindThemeChartRedraw` in `lib/report-view.js`
24+
resolve a per-theme legend color (>= 4.5:1 on each `--bg-color`) and
25+
redraw live charts on radio/`prefers-color-scheme` changes. Guards:
2226
`tests/unit/theme-css.test.js` (the two dark blocks must stay
2327
byte-identical; all hex colors live in the palette blocks; dark palette
2428
passes the same WCAG AA math as `tests/unit/css-a11y.test.js`)

‎lib/report-view.js‎

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
370462
export 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

447544
export 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

Comments
 (0)