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
+3
View File
@@ -9,6 +9,9 @@ lives in [`../PLAN.md`](../PLAN.md), the active web finalization in
## Foundation & platform
- [design-system.md](design-system.md) — the design tokens (colour /
spacing / typography), the light/dark theming mechanism, and the
component migration conventions.
- [navigation.md](navigation.md) — routes, the sidebar tabs, and the
state-preservation rules across view/tab switches.
- [storage.md](storage.md) — the `KeyStore` and `Cache` abstractions and
+103
View File
@@ -0,0 +1,103 @@
# Design system — tokens & theming
The client's visual language lives in a single set of CSS custom
properties (design tokens). Components reference the tokens
(`var(--color-…)`, `var(--space-…)`) instead of literal hex/px, so a
palette change is a one-file edit and the light and dark themes stay in
lockstep.
## Where the tokens live
- [`src/lib/theme/tokens.css`](../frontend/src/lib/theme/tokens.css) —
every token. Theme-independent scales (spacing, radii, typography) sit
in `:root`; the colour/shadow palette is defined twice, once for dark
(`:root, :root[data-theme="dark"]`) and once for light
(`:root[data-theme="light"]`).
- [`src/lib/theme/base.css`](../frontend/src/lib/theme/base.css) — a
small global baseline (document background, default text/typography, a
token-driven focus ring, selection colour). Everything else stays in
component-scoped `<style>`.
- Both are imported once, in the root
[`+layout.svelte`](../frontend/src/routes/+layout.svelte); a plain CSS
import is global (Svelte only scopes `<style>` blocks).
## Token reference
### Colour (theme-dependent)
| Token | Role |
|---|---|
| `--color-bg` | Application backdrop, header, bottom-tab bar. |
| `--color-surface` | Panels (sidebar, cards). |
| `--color-surface-raised` | Form controls and raised cards. |
| `--color-surface-overlay` | Floating surfaces (menus, drawers, popovers). |
| `--color-surface-hover` | Hover state on interactive surfaces. |
| `--color-border-subtle` | Structural chrome edges. |
| `--color-border` | Default control borders. |
| `--color-border-strong` | Emphasis / focus borders. |
| `--color-text` | Primary text. |
| `--color-text-muted` | Secondary text. |
| `--color-text-faint` | Tertiary text, placeholders. |
| `--color-accent` (`-hover`, `-active`, `-contrast`, `-subtle`) | Brand periwinkle: links, active states, fills, tints. `-contrast` is the text colour on an accent fill. |
| `--color-danger` (`-contrast`, `-subtle`) | Errors, destructive actions. |
| `--color-success` (`-subtle`) | Success / positive. |
| `--color-warning` (`-subtle`) | Warnings. |
| `--color-focus` | Focus-ring colour (aliases `--color-accent`). |
| `--shadow-sm` / `--shadow-md` / `--shadow-lg` | Elevation. |
`*-subtle` colours are translucent (`rgba`) so they tint whatever sits
behind them; `*-contrast` colours are opaque text colours meant to sit
on top of the matching solid fill.
### Scales (theme-independent)
- Spacing: `--space-1` (0.25rem) … `--space-6` (2rem), `--space-8` (3rem).
- Radii: `--radius-sm` 4px, `--radius-md` 6px, `--radius-lg` 10px,
`--radius-pill`.
- Typography: `--font-sans`, `--font-mono`; sizes `--text-xs`
`--text-xl`; `--leading-tight` / `--leading-normal`; weights
`--weight-normal` / `--weight-medium` / `--weight-semibold`.
## Theming (light / dark / system)
The resolved theme is written to `data-theme` on `<html>`, which selects
the colour block in `tokens.css`.
- A pre-paint guard in [`app.html`](../frontend/src/app.html) reads the
stored choice from `localStorage["galaxy-theme"]` and sets `data-theme`
before the app boots, so first paint never flashes.
- [`src/lib/theme/theme.svelte.ts`](../frontend/src/lib/theme/theme.svelte.ts)
is the runtime store: `theme.choice` (`system` | `light` | `dark`),
`theme.resolved` (`light` | `dark`), and `theme.setChoice(…)`. It
persists the choice, applies `data-theme`, and — while the choice is
`system` — follows OS theme changes via `matchMedia`.
- The account menu (`account-menu.svelte`) exposes the picker. The
default is `dark`; `system` follows the OS.
The `app.html` guard and the store deliberately duplicate the
resolution logic (one runs before modules load, the other after) — keep
them in sync.
## Conventions
- No literal theme colours in component `<style>`. Use a token; if none
fits, add one to `tokens.css` rather than hard-coding.
- Map by role, not by hex: a form-control background is
`--color-surface-raised` even if its old value happened to match a
hover colour.
- Directional one-off drop shadows that are not part of the elevation
scale may stay as literal `rgba(0, 0, 0, …)` (they read acceptably in
both themes); reach for `--shadow-*` for standard elevation.
- Spacing-scale adoption is gradual — colour tokens are the priority;
existing one-off paddings are migrated opportunistically, not churned
en masse.
## Migration status
The token system and the always-visible in-game chrome (header, account
menu, sidebar frame, tab bar, bottom tabs, shell background) reference
tokens and switch cleanly between light and dark. The view bodies
(calculator, inspectors, tables, lobby, auth, map overlays, battle,
mail) are migrated incrementally; until a view is migrated its
hard-coded colours stay dark in both themes. F1 is complete when no
literal theme colours remain in component `<style>` blocks.
+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;
+88
View File
@@ -0,0 +1,88 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// The store is a module singleton constructed on first import: it reads
// localStorage and `matchMedia` in its constructor. Each test therefore
// stubs `matchMedia` and resets the module registry, then imports a
// freshly-constructed store via `freshStore`.
const STORAGE_KEY = "galaxy-theme";
function stubMatchMedia(prefersLight: boolean): void {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: (query: string) => ({
matches: query.includes("light") ? prefersLight : false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
async function freshStore(
prefersLight = false,
): Promise<typeof import("../src/lib/theme/theme.svelte")> {
stubMatchMedia(prefersLight);
vi.resetModules();
return import("../src/lib/theme/theme.svelte");
}
describe("theme store", () => {
beforeEach(() => {
localStorage.clear();
delete document.documentElement.dataset.theme;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("defaults to dark and applies it to the document", async () => {
const { theme } = await freshStore();
expect(theme.choice).toBe("dark");
expect(theme.resolved).toBe("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("persists an explicit choice and writes data-theme", async () => {
const { theme, THEME_STORAGE_KEY } = await freshStore();
expect(THEME_STORAGE_KEY).toBe(STORAGE_KEY);
theme.setChoice("light");
expect(theme.choice).toBe("light");
expect(theme.resolved).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
theme.setChoice("dark");
expect(theme.resolved).toBe("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
expect(localStorage.getItem(STORAGE_KEY)).toBe("dark");
});
it("reads the stored choice on construction", async () => {
localStorage.setItem(STORAGE_KEY, "light");
const { theme } = await freshStore();
expect(theme.choice).toBe("light");
expect(theme.resolved).toBe("light");
});
it("resolves system to the OS preference", async () => {
const { theme } = await freshStore(true);
theme.setChoice("system");
expect(theme.choice).toBe("system");
expect(theme.resolved).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
});
it("falls back to dark for an unrecognised stored value", async () => {
localStorage.setItem(STORAGE_KEY, "neon");
const { theme } = await freshStore();
expect(theme.choice).toBe("dark");
});
});