feat(ui): design tokens + light/dark theming, migrate in-game chrome (F1a)
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:
Ilia Denisov
2026-05-22 07:02:13 +02:00
parent 44ed0a90eb
commit 973480d812
16 changed files with 560 additions and 53 deletions
+91
View File
@@ -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();