/** * 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': '#0F766E', '--brand-strong': '#0C5F58', '--brand-bg': 'rgba(15, 118, 110, 0.10)', '--status-above': '#36743F', '--status-above-bg': 'rgba(54, 116, 63, 0.12)', '--status-below': '#A9481F', '--status-below-bg': 'rgba(249, 115, 96, 0.15)', '--text-primary': '#1C2731', '--text-secondary': '#4A5560', '--text-muted': '#5F6A75', '--text-inverse': '#FFFFFF', '--bg-card': '#FFFFFF', '--bg-secondary': '#F5EFE6', '--border': '#E5E7EB', '--border-strong': '#D3D7DD', '--surface-inverse': '#0F766E', '--chart-1': '#0F766E', '--chart-2': '#36743F', '--chart-3': '#806200', '--chart-4': '#A9481F', '--chart-5': '#2F6F8F', '--chart-6': '#5F4FA8', '--chart-grid': '#E5E7EB', '--chart-reference': '#5F6A75', '--phase-primary': '#0F766E', '--phase-secondary-text': '#2A6180', '--medal-gold': '#806200', '--medal-silver': '#6B7580', '--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 = [ '#0F766E', '#36743F', '#806200', '#A9481F', '#2F6F8F', '#5F4FA8', '#0E7A86', '#8A4A6B', ]; /** * 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})`; }