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
+20
View File
@@ -11,6 +11,26 @@
margin: 0;
}
</style>
<script>
// Pre-paint theme guard: resolve the stored theme choice before
// the app boots so first paint matches and never flashes. Mirrors
// $lib/theme/theme.svelte.ts; keep the two in sync.
(function () {
try {
var c = localStorage.getItem("galaxy-theme");
var resolved =
c === "light" || c === "dark"
? c
: window.matchMedia &&
window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
document.documentElement.dataset.theme = resolved;
} catch (e) {
document.documentElement.dataset.theme = "dark";
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+44 -18
View File
@@ -7,8 +7,20 @@ Sessions and Theme) take over.
-->
<script lang="ts">
import { onMount } from "svelte";
import { i18n, SUPPORTED_LOCALES, type Locale } from "$lib/i18n/index.svelte";
import {
i18n,
SUPPORTED_LOCALES,
type Locale,
type TranslationKey,
} from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { theme, type ThemeChoice } from "$lib/theme/theme.svelte";
const THEME_CHOICES: ReadonlyArray<{ id: ThemeChoice; key: TranslationKey }> = [
{ id: "system", key: "game.shell.menu.theme_system" },
{ id: "light", key: "game.shell.menu.theme_light" },
{ id: "dark", key: "game.shell.menu.theme_dark" },
];
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
@@ -27,6 +39,11 @@ Sessions and Theme) take over.
i18n.setLocale(value);
}
function selectTheme(event: Event): void {
const value = (event.target as HTMLSelectElement).value as ThemeChoice;
theme.setChoice(value);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
@@ -69,10 +86,19 @@ Sessions and Theme) take over.
<button type="button" role="menuitem" data-testid="account-menu-sessions" disabled>
{i18n.t("game.shell.menu.sessions")}
</button>
<button type="button" role="menuitem" data-testid="account-menu-theme" disabled>
{i18n.t("game.shell.menu.theme")}
</button>
<label class="locale" data-testid="account-menu-language">
<label class="field" data-testid="account-menu-theme">
<span>{i18n.t("game.shell.menu.theme")}</span>
<select
data-testid="account-menu-theme-select"
value={theme.choice}
onchange={selectTheme}
>
{#each THEME_CHOICES as entry (entry.id)}
<option value={entry.id}>{i18n.t(entry.key)}</option>
{/each}
</select>
</label>
<label class="field" data-testid="account-menu-language">
<span>{i18n.t("game.shell.menu.language")}</span>
<select
data-testid="account-menu-language-select"
@@ -106,12 +132,12 @@ Sessions and Theme) take over.
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.trigger:hover {
background: #1c2238;
background: var(--color-surface-hover);
}
.surface {
position: absolute;
@@ -120,10 +146,10 @@ Sessions and Theme) take over.
min-width: 12rem;
display: flex;
flex-direction: column;
background: #14182a;
border: 1px solid #2a3150;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 50;
}
.surface > button,
@@ -137,23 +163,23 @@ Sessions and Theme) take over.
cursor: pointer;
}
.surface > button:hover:not(:disabled) {
background: #1c2238;
background: var(--color-surface-hover);
}
.surface > button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.locale {
.field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.locale select {
.field select {
font: inherit;
background: #1c2238;
background: var(--color-surface-raised);
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.15rem 0.35rem;
}
</style>
+7 -7
View File
@@ -84,10 +84,10 @@ absent until Phase 24 wires push-event state.
align-items: center;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: #0a0e1a;
color: #e8eaf6;
border-bottom: 1px solid #20253a;
font-family: system-ui, sans-serif;
background: var(--color-bg);
color: var(--color-text);
border-bottom: 1px solid var(--color-border-subtle);
font-family: var(--font-sans);
}
.left,
.right {
@@ -108,13 +108,13 @@ absent until Phase 24 wires push-event state.
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
display: none;
}
.sidebar-toggle:hover {
background: #1c2238;
background: var(--color-surface-hover);
}
@media (min-width: 768px) and (max-width: 1023.98px) {
.sidebar-toggle {
+3
View File
@@ -100,6 +100,9 @@ const en = {
"game.shell.menu.settings": "settings",
"game.shell.menu.sessions": "sessions",
"game.shell.menu.theme": "theme",
"game.shell.menu.theme_system": "system",
"game.shell.menu.theme_light": "light",
"game.shell.menu.theme_dark": "dark",
"game.shell.menu.language": "language",
"game.shell.menu.logout": "logout",
"game.shell.coming_soon": "coming soon",
+3
View File
@@ -101,6 +101,9 @@ const ru: Record<keyof typeof en, string> = {
"game.shell.menu.settings": "настройки",
"game.shell.menu.sessions": "сессии",
"game.shell.menu.theme": "тема",
"game.shell.menu.theme_system": "системная",
"game.shell.menu.theme_light": "светлая",
"game.shell.menu.theme_dark": "тёмная",
"game.shell.menu.language": "язык",
"game.shell.menu.logout": "выйти",
"game.shell.coming_soon": "скоро будет",
+14 -14
View File
@@ -205,10 +205,10 @@ destinations beats the duplication.
display: flex;
justify-content: space-around;
align-items: stretch;
background: #0a0e1a;
color: #e8eaf6;
border-top: 1px solid #20253a;
font-family: system-ui, sans-serif;
background: var(--color-bg);
color: var(--color-text);
border-top: 1px solid var(--color-border-subtle);
font-family: var(--font-sans);
}
.tabs button {
flex: 1;
@@ -220,13 +220,13 @@ destinations beats the duplication.
font: inherit;
font-size: 0.75rem;
background: transparent;
color: #aab;
color: var(--color-text-muted);
border: 0;
cursor: pointer;
}
.tabs button.active {
color: #e8eaf6;
background: #1c2238;
color: var(--color-text);
background: var(--color-surface-hover);
}
.tabs .icon {
font-size: 1.25rem;
@@ -240,12 +240,12 @@ destinations beats the duplication.
overflow-y: auto;
display: flex;
flex-direction: column;
background: #14182a;
color: #e8eaf6;
border-top: 1px solid #2a3150;
background: var(--color-surface-overlay);
color: var(--color-text);
border-top: 1px solid var(--color-border);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
z-index: 50;
font-family: system-ui, sans-serif;
font-family: var(--font-sans);
}
.drawer > button,
.drawer > details > summary {
@@ -259,7 +259,7 @@ destinations beats the duplication.
}
.drawer > button:hover,
.drawer > details > summary:hover {
background: #1c2238;
background: var(--color-surface-hover);
}
.drawer > details > summary {
list-style: none;
@@ -279,7 +279,7 @@ destinations beats the duplication.
display: flex;
flex-direction: column;
padding-left: 0.5rem;
border-left: 1px solid #2a3150;
border-left: 1px solid var(--color-border);
margin: 0 0.5rem 0.25rem;
}
.sub > button {
@@ -292,6 +292,6 @@ destinations beats the duplication.
cursor: pointer;
}
.sub > button:hover {
background: #1c2238;
background: var(--color-surface-hover);
}
</style>
+4 -4
View File
@@ -108,9 +108,9 @@ through the binding without extra plumbing.
flex-direction: column;
width: 18rem;
min-width: 18rem;
background: #0e1322;
color: #e8eaf6;
border-left: 1px solid #20253a;
background: var(--color-surface);
color: var(--color-text);
border-left: 1px solid var(--color-border-subtle);
}
.head {
display: flex;
@@ -130,7 +130,7 @@ through the binding without extra plumbing.
cursor: pointer;
}
.close:hover {
color: #6d8cff;
color: var(--color-accent);
}
.content {
flex: 1;
+6 -6
View File
@@ -51,24 +51,24 @@ flips it on.
display: flex;
gap: 0.25rem;
padding: 0.5rem 0.5rem 0;
border-bottom: 1px solid #20253a;
font-family: system-ui, sans-serif;
border-bottom: 1px solid var(--color-border-subtle);
font-family: var(--font-sans);
}
.tab-bar button {
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.75rem;
background: transparent;
color: #aab;
color: var(--color-text-muted);
border: 0;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.tab-bar button.active {
color: #e8eaf6;
border-bottom-color: #6d8cff;
color: var(--color-text);
border-bottom-color: var(--color-accent);
}
.tab-bar button:hover:not(.active) {
color: #e8eaf6;
color: var(--color-text);
}
</style>
+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);
}
+4 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import "$lib/theme/tokens.css";
import "$lib/theme/base.css";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -99,7 +101,7 @@
<style>
.status {
padding: 2rem;
font-family: system-ui, sans-serif;
padding: var(--space-6);
font-family: var(--font-sans);
}
</style>
@@ -594,8 +594,8 @@ fresh.
display: flex;
flex-direction: column;
min-height: 100vh;
background: #0a0e1a;
color: #e8eaf6;
background: var(--color-bg);
color: var(--color-text);
}
.body {
flex: 1;