/** * Theme selection store. * * Holds the user's theme choice (system / light / dark), resolves it to a * concrete light-or-dark theme, persists the choice to `localStorage`, * and writes the resolved theme to `data-theme` on the document root so * the token overrides in `tokens.css` take effect. * * First paint is handled by the inline guard in `app.html`, which sets * `data-theme` from the same `localStorage` key before the app boots; * this store mirrors that logic and takes over once mounted, including * reacting to OS theme changes while the choice is `system`. * * On top of the persisted choice the store carries an ephemeral * `override` channel: while non-null it short-circuits `resolved` so the * in-game light/dark toggle can flip the document theme without touching * the lobby-side preference. The override lives in memory only — leaving * the game shell (or any other consumer calling `clearOverride()`) * re-projects the persisted choice. */ /** A user's theme preference; `system` follows the OS setting. */ export type ThemeChoice = "system" | "light" | "dark"; /** A concrete theme actually applied to the document. */ export type ResolvedTheme = "light" | "dark"; /** `localStorage` key shared with the pre-paint guard in `app.html`. */ export const THEME_STORAGE_KEY = "galaxy-theme"; const SYSTEM_LIGHT_QUERY = "(prefers-color-scheme: light)"; function readStoredChoice(): ThemeChoice { if (typeof localStorage === "undefined") return "system"; const value = localStorage.getItem(THEME_STORAGE_KEY); return value === "light" || value === "dark" || value === "system" ? value : "system"; } function systemTheme(): ResolvedTheme { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { return "dark"; } return window.matchMedia(SYSTEM_LIGHT_QUERY).matches ? "light" : "dark"; } /** * Reactive theme store. `choice` is the persisted preference and * `resolved` is the concrete theme after applying `system`. */ class ThemeStore { #choice = $state(readStoredChoice()); #system = $state(systemTheme()); #override = $state(null); constructor() { if ( typeof window !== "undefined" && typeof window.matchMedia === "function" ) { window .matchMedia(SYSTEM_LIGHT_QUERY) .addEventListener("change", (event: MediaQueryListEvent) => { this.#system = event.matches ? "light" : "dark"; if (this.#choice === "system" && this.#override === null) { this.#apply(); } }); } this.#apply(); } /** The persisted user preference. */ get choice(): ThemeChoice { return this.#choice; } /** * The current ephemeral override (set by the in-game toggle) or * `null` when no override is active. */ get override(): ResolvedTheme | null { return this.#override; } /** The concrete theme currently applied to the document. */ get resolved(): ResolvedTheme { if (this.#override !== null) return this.#override; return this.#choice === "system" ? this.#system : this.#choice; } /** Persist a new preference and apply it to the document. */ setChoice(choice: ThemeChoice): void { this.#choice = choice; if (typeof localStorage !== "undefined") { localStorage.setItem(THEME_STORAGE_KEY, choice); } this.#apply(); } /** * Set the ephemeral override. The override is not persisted; it lives * until `clearOverride()` (or another `setOverride` call) replaces it. */ setOverride(value: ResolvedTheme): void { this.#override = value; this.#apply(); } /** * Drop the ephemeral override so the document re-projects the * persisted preference. Cheap to call on every game-shell unmount — * a no-op when no override was set. */ clearOverride(): void { if (this.#override === null) return; this.#override = null; this.#apply(); } #apply(): void { if (typeof document !== "undefined") { document.documentElement.dataset.theme = this.resolved; } } } /** The application-wide theme store singleton. */ export const theme = new ThemeStore();