164 lines
5.2 KiB
TypeScript
164 lines
5.2 KiB
TypeScript
/**
|
|||
|
|
* 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<string, string> = {
|
||
|
|
'--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<T extends readonly ThemeToken[]>(
|
||
|
|
...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})`;
|
||
|
|
}
|