feat(ui): design tokens + light/dark theming, migrate in-game chrome (F1a)
Tests · UI / test (push) Successful in 2m4s
Tests · UI / test (push) Successful in 2m4s
Introduce the shared design-token system under ui/frontend/src/lib/theme/: tokens.css (dark default + light palette, plus spacing/radii/typography scales), base.css global baseline (document background, text, token focus ring, selection), and theme.svelte.ts (system/light/dark choice, persisted to localStorage, applied via data-theme on <html>). A pre-paint guard in app.html resolves the theme before the app boots to avoid a flash, and the theme picker is wired into the previously-disabled account-menu stub. Migrate the always-visible in-game chrome to the tokens (header, account menu, sidebar, tab-bar, bottom-tabs, shell background): dark renders as before, light comes for free. The default stays dark during the incremental migration; the remaining view bodies migrate in F1b. Docs: ui/docs/design-system.md (+ index entry). Test: tests/theme.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 "dark";
|
||||
const value = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return value === "light" || value === "dark" || value === "system"
|
||||
? value
|
||||
: "dark";
|
||||
}
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user