/** * Bridge between the CSS token layer and the bits of the UI that are painted * from JavaScript — Chart.js datasets and Leaflet markers. * * Those two can't use var(), so before this they carried their own hardcoded * hexes. That is how the chart palette drifted off-brand (a stray purple and * Flat UI's stock blue) and why charts would have ignored the dark theme * entirely. Reading the computed custom property keeps one source of truth. */ 'use client'; import { useEffect, useState } from 'react'; export type ThemeToken = | '--brand' | '--brand-strong' | '--brand-bg' | '--status-above' | '--status-above-bg' | '--status-below' | '--status-below-bg' | '--text-primary' | '--text-secondary' | '--text-muted' | '--text-inverse' | '--bg-card' | '--bg-secondary' | '--border' | '--border-strong' | '--surface-inverse' | '--chart-1' | '--chart-2' | '--chart-3' | '--chart-4' | '--chart-5' | '--chart-6' | '--chart-grid' | '--chart-reference' | '--phase-primary' | '--phase-secondary-text' | '--medal-gold' | '--medal-silver' | '--medal-bronze'; /** Values used before hydration and in any non-DOM context (SSR, tests). */ const FALLBACK: Record = { '--brand': '#584a9b', '--brand-strong': '#473c80', '--brand-bg': 'rgba(88, 74, 155, 0.10)', '--status-above': '#0e6e66', '--status-above-bg': 'rgba(14, 110, 102, 0.10)', '--status-below': '#9a5b00', '--status-below-bg': 'rgba(154, 91, 0, 0.11)', '--text-primary': '#16202a', '--text-secondary': '#4a545f', '--text-muted': '#5c6570', '--text-inverse': '#f7f7f4', '--bg-card': '#fcfcfa', '--bg-secondary': '#e9e8e2', '--border': '#dedcd5', '--border-strong': '#cdcac1', '--surface-inverse': '#16202a', '--chart-1': '#584a9b', '--chart-2': '#0e6e66', '--chart-3': '#9a5b00', '--chart-4': '#8a7cc9', '--chart-5': '#3e9c92', '--chart-6': '#c9903d', '--chart-grid': '#dedcd5', '--chart-reference': '#5c6570', '--phase-primary': '#4a6072', '--phase-secondary-text': '#4e4570', '--medal-gold': '#a67c00', '--medal-silver': '#6f7580', '--medal-bronze': '#8a5a2b', }; /** Read one token. Safe to call during SSR — returns the light-theme value. */ export function token(name: ThemeToken): string { if (typeof window === 'undefined') return FALLBACK[name]; const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return value || FALLBACK[name]; } /** * Same as `token`, but the component re-renders when the OS theme flips so * canvas-painted colours don't get stranded in the previous theme. */ export function useThemeTokens( ...names: T ): { [K in keyof T]: string } { const read = () => names.map((n) => token(n)) as { [K in keyof T]: string }; const [values, setValues] = useState(read); useEffect(() => { // Only commit when something actually changed. In the light theme the // computed values equal the fallbacks, so this is a no-op — which matters // because a state update here re-renders the chart and makes Chart.js // resize a canvas that may not be laid out yet. const sync = () => setValues((prev) => (sameValues(prev, read()) ? prev : read())); sync(); const mq = window.matchMedia('(prefers-color-scheme: dark)'); mq.addEventListener('change', sync); return () => mq.removeEventListener('change', sync); // `names` is a fixed-length literal tuple at every call site. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return values; } function sameValues(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } const SERIES = [ '--series-1', '--series-2', '--series-3', '--series-4', '--series-5', '--series-6', '--series-7', '--series-8', ] as const; const SERIES_FALLBACK = [ '#584a9b', '#0e6e66', '#9a5b00', '#a03a5e', '#2f5f8f', '#4a6b2f', '#7a3f7a', '#97442a', ]; /** * Resolved series colours for Chart.js datasets, in the same index order as * CHART_COLORS — so a school keeps its colour between the canvas and the DOM * swatch beside it. Re-reads when the OS theme flips. */ export function useSeriesColors(): string[] { const read = () => typeof window === 'undefined' ? SERIES_FALLBACK : SERIES.map((n, i) => { const v = getComputedStyle(document.documentElement).getPropertyValue(n).trim(); return v || SERIES_FALLBACK[i]; }); const [colors, setColors] = useState(read); useEffect(() => { // See useThemeTokens: skip the commit when nothing changed, so mounting a // chart doesn't cost an extra render. const sync = () => setColors((prev) => (sameValues(prev, read()) ? prev : read())); sync(); const mq = window.matchMedia('(prefers-color-scheme: dark)'); mq.addEventListener('change', sync); return () => mq.removeEventListener('change', sync); }, []); return colors; } /** rgba() built from a token's hex, for fills that need their own alpha. */ export function alpha(name: ThemeToken, a: number): string { const hex = token(name); const m = /^#([0-9a-f]{6})$/i.exec(hex); if (!m) return hex; const n = parseInt(m[1], 16); return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`; }