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
+29
View File
@@ -0,0 +1,29 @@
/*
* Global element baseline, layered on top of the design tokens. Kept
* deliberately small: it sets the document background, default text
* colour/typography, a token-driven focus ring, and selection colour.
* Component-scoped styles still own everything else.
*
* The focus-ring rule uses :where() so its specificity is zero and any
* component that defines its own focus treatment wins without !important.
*/
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:where(*):focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
::selection {
background: var(--color-accent-subtle);
}
+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();
+139
View File
@@ -0,0 +1,139 @@
/*
* Galaxy UI design tokens.
*
* The single source of every theme value in the client. Components must
* reference these custom properties (`var(--color-…)`, `var(--space-…)`)
* instead of literal hex/px so a palette change is a one-file edit and
* the light/dark themes stay in sync.
*
* Structure:
* :root theme-independent scales (space, radii,
* typography) — identical in every theme.
* :root, [data-theme=dark] the dark palette (also the default when no
* theme is set, so first paint is never bare).
* [data-theme=light] the light palette overrides.
*
* The resolved theme is written to `data-theme` on <html> by the
* pre-paint guard in `app.html` and thereafter by
* `$lib/theme/theme.svelte.ts`. See `ui/docs/design-system.md`.
*/
:root {
/* Spacing scale (4px base). */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--space-8: 3rem;
/* Corner radii. */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--radius-pill: 999px;
/* Typography. */
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas,
monospace;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-md: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--leading-tight: 1.25;
--leading-normal: 1.5;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
}
/*
* Dark palette — the project's native look and the default. Listed under
* both :root and [data-theme=dark] so a document with no data-theme yet
* (e.g. the pre-paint instant) still renders dark rather than unstyled.
*/
:root,
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: #0a0e1a;
--color-surface: #0e1322;
--color-surface-raised: #161d33;
--color-surface-overlay: #161b2e;
--color-surface-hover: #1c2238;
--color-border-subtle: #20253a;
--color-border: #2a3150;
--color-border-strong: #3a4470;
--color-text: #e8eaf6;
--color-text-muted: #9aa4c6;
--color-text-faint: #6b7396;
--color-accent: #6d8cff;
--color-accent-hover: #88a0ff;
--color-accent-active: #5a78f0;
--color-accent-contrast: #0a0e1a;
--color-accent-subtle: rgba(109, 140, 255, 0.14);
--color-danger: #e07a7a;
--color-danger-contrast: #0a0e1a;
--color-danger-subtle: rgba(224, 122, 122, 0.14);
--color-success: #5fb98c;
--color-success-subtle: rgba(95, 185, 140, 0.14);
--color-warning: #e0b15a;
--color-warning-subtle: rgba(224, 177, 90, 0.14);
--color-focus: var(--color-accent);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.45);
}
/* Light palette. */
:root[data-theme="light"] {
color-scheme: light;
--color-bg: #f3f5fb;
--color-surface: #ffffff;
--color-surface-raised: #f7f9fd;
--color-surface-overlay: #ffffff;
--color-surface-hover: #eaeef8;
--color-border-subtle: #e6eaf3;
--color-border: #d3d9e8;
--color-border-strong: #aeb8d4;
--color-text: #1a2138;
--color-text-muted: #59617e;
--color-text-faint: #8b93ad;
--color-accent: #4a63d8;
--color-accent-hover: #3a52c8;
--color-accent-active: #2f46b5;
--color-accent-contrast: #ffffff;
--color-accent-subtle: rgba(74, 99, 216, 0.1);
--color-danger: #c84d4d;
--color-danger-contrast: #ffffff;
--color-danger-subtle: rgba(200, 77, 77, 0.1);
--color-success: #2f8f63;
--color-success-subtle: rgba(47, 143, 99, 0.12);
--color-warning: #b07d24;
--color-warning-subtle: rgba(176, 125, 36, 0.14);
--color-focus: var(--color-accent);
--shadow-sm: 0 1px 2px rgba(20, 28, 51, 0.08);
--shadow-md: 0 4px 12px rgba(20, 28, 51, 0.1);
--shadow-lg: 0 8px 24px rgba(20, 28, 51, 0.14);
}