Files
galaxy-game/ui/frontend/src/lib/theme/theme.svelte.ts
T
Ilia Denisov e193f3ca88
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Failing after 2m7s
feat(ui): default theme to system (follow OS light/dark)
Light has been signed off, so the theme store's default choice is now
`system` (it was `dark` during the incremental migration). This matches
the app.html pre-paint guard, which already resolved an unset choice via
prefers-color-scheme — removing the brief boot-time mismatch where the
store re-pinned dark. Users still pin light/dark via the account-menu
picker. Updates the store default + its test and the design-system /
finalize-plan docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:36:17 +02:00

92 lines
2.8 KiB
TypeScript

/**
* 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`.
*/
/** 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<ThemeChoice>(readStoredChoice());
#system = $state<ResolvedTheme>(systemTheme());
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.#apply();
});
}
this.#apply();
}
/** The persisted user preference. */
get choice(): ThemeChoice {
return this.#choice;
}
/** The concrete theme currently applied to the document. */
get resolved(): ResolvedTheme {
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();
}
#apply(): void {
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = this.resolved;
}
}
}
/** The application-wide theme store singleton. */
export const theme = new ThemeStore();