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
+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 {