e193f3ca88
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>
92 lines
2.8 KiB
TypeScript
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();
|