feat(ui): design tokens + light/dark theming, migrate in-game chrome (F1a)
Tests · UI / test (push) Successful in 2m4s
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "скоро будет",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user