Files
school_compare/nextjs-app/lib/theme.ts
T

164 lines
5.2 KiB
TypeScript
Raw Normal View History

/**
* 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': '#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<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 = [
'#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})`;
}