// Colour scheme (D-06; assumption A6 superseded). The admin follows the // system preference by default; light and dark are manual choices the admin // makes on the rail's top button and keeps per account (usePreferences). The // .dark class on the document element swaps the design's dark tokens in // main.css; the sidebar stays dark in both modes and gains its #222b40 right // border in dark mode. export const DARK_QUERY = '(prefers-color-scheme: dark)' /** The admin's theme: follow the system, or always light or dark. */ export type ThemeChoice = 'system' | 'light' | 'dark' /** The theme choices in the order the rail button cycles through them. */ export const THEME_CHOICES: readonly ThemeChoice[] = ['system', 'light', 'dark'] export function isThemeChoice(value: unknown): value is ThemeChoice { return typeof value === 'string' && (THEME_CHOICES as readonly string[]).includes(value) } /** * Applies a theme choice. light and dark set the class once and subscribe * to nothing; system applies the current preference and follows changes. * Returns the unsubscribe function. */ export function applyColorScheme(win: Window = window, choice: ThemeChoice = 'system'): () => void { const root = win.document.documentElement if (choice !== 'system') { root.classList.toggle('dark', choice === 'dark') return () => undefined } if (typeof win.matchMedia !== 'function') { root.classList.remove('dark') return () => undefined } const query = win.matchMedia(DARK_QUERY) const apply = (dark: boolean): void => { root.classList.toggle('dark', dark) } apply(query.matches) const onChange = (event: MediaQueryListEvent): void => apply(event.matches) query.addEventListener('change', onChange) return () => query.removeEventListener('change', onChange) }