feat(ui): visual design system — tokens + light/dark theming (F1) #26
@@ -9,6 +9,9 @@ lives in [`../PLAN.md`](../PLAN.md), the active web finalization in
|
|||||||
|
|
||||||
## Foundation & platform
|
## 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
|
- [navigation.md](navigation.md) — routes, the sidebar tabs, and the
|
||||||
state-preservation rules across view/tab switches.
|
state-preservation rules across view/tab switches.
|
||||||
- [storage.md](storage.md) — the `KeyStore` and `Cache` abstractions and
|
- [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;
|
margin: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -7,8 +7,20 @@ Sessions and Theme) take over.
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
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 { 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 open = $state(false);
|
||||||
let rootEl: HTMLDivElement | null = $state(null);
|
let rootEl: HTMLDivElement | null = $state(null);
|
||||||
@@ -27,6 +39,11 @@ Sessions and Theme) take over.
|
|||||||
i18n.setLocale(value);
|
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 {
|
function onKeyDown(event: KeyboardEvent): void {
|
||||||
if (event.key === "Escape" && open) {
|
if (event.key === "Escape" && open) {
|
||||||
open = false;
|
open = false;
|
||||||
@@ -69,10 +86,19 @@ Sessions and Theme) take over.
|
|||||||
<button type="button" role="menuitem" data-testid="account-menu-sessions" disabled>
|
<button type="button" role="menuitem" data-testid="account-menu-sessions" disabled>
|
||||||
{i18n.t("game.shell.menu.sessions")}
|
{i18n.t("game.shell.menu.sessions")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" data-testid="account-menu-theme" disabled>
|
<label class="field" data-testid="account-menu-theme">
|
||||||
{i18n.t("game.shell.menu.theme")}
|
<span>{i18n.t("game.shell.menu.theme")}</span>
|
||||||
</button>
|
<select
|
||||||
<label class="locale" data-testid="account-menu-language">
|
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>
|
<span>{i18n.t("game.shell.menu.language")}</span>
|
||||||
<select
|
<select
|
||||||
data-testid="account-menu-language-select"
|
data-testid="account-menu-language-select"
|
||||||
@@ -106,12 +132,12 @@ Sessions and Theme) take over.
|
|||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid #2a3150;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.trigger:hover {
|
.trigger:hover {
|
||||||
background: #1c2238;
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
.surface {
|
.surface {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -120,10 +146,10 @@ Sessions and Theme) take over.
|
|||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #14182a;
|
background: var(--color-surface-overlay);
|
||||||
border: 1px solid #2a3150;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
box-shadow: var(--shadow-lg);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
.surface > button,
|
.surface > button,
|
||||||
@@ -137,23 +163,23 @@ Sessions and Theme) take over.
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.surface > button:hover:not(:disabled) {
|
.surface > button:hover:not(:disabled) {
|
||||||
background: #1c2238;
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
.surface > button:disabled {
|
.surface > button:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.locale {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.locale select {
|
.field select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
background: #1c2238;
|
background: var(--color-surface-raised);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid #2a3150;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.15rem 0.35rem;
|
padding: 0.15rem 0.35rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ absent until Phase 24 wires push-event state.
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: #0a0e1a;
|
background: var(--color-bg);
|
||||||
color: #e8eaf6;
|
color: var(--color-text);
|
||||||
border-bottom: 1px solid #20253a;
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
font-family: system-ui, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
.left,
|
.left,
|
||||||
.right {
|
.right {
|
||||||
@@ -108,13 +108,13 @@ absent until Phase 24 wires push-event state.
|
|||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid #2a3150;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.sidebar-toggle:hover {
|
.sidebar-toggle:hover {
|
||||||
background: #1c2238;
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) and (max-width: 1023.98px) {
|
@media (min-width: 768px) and (max-width: 1023.98px) {
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ const en = {
|
|||||||
"game.shell.menu.settings": "settings",
|
"game.shell.menu.settings": "settings",
|
||||||
"game.shell.menu.sessions": "sessions",
|
"game.shell.menu.sessions": "sessions",
|
||||||
"game.shell.menu.theme": "theme",
|
"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.language": "language",
|
||||||
"game.shell.menu.logout": "logout",
|
"game.shell.menu.logout": "logout",
|
||||||
"game.shell.coming_soon": "coming soon",
|
"game.shell.coming_soon": "coming soon",
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.shell.menu.settings": "настройки",
|
"game.shell.menu.settings": "настройки",
|
||||||
"game.shell.menu.sessions": "сессии",
|
"game.shell.menu.sessions": "сессии",
|
||||||
"game.shell.menu.theme": "тема",
|
"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.language": "язык",
|
||||||
"game.shell.menu.logout": "выйти",
|
"game.shell.menu.logout": "выйти",
|
||||||
"game.shell.coming_soon": "скоро будет",
|
"game.shell.coming_soon": "скоро будет",
|
||||||
|
|||||||
@@ -205,10 +205,10 @@ destinations beats the duplication.
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
background: #0a0e1a;
|
background: var(--color-bg);
|
||||||
color: #e8eaf6;
|
color: var(--color-text);
|
||||||
border-top: 1px solid #20253a;
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
font-family: system-ui, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
.tabs button {
|
.tabs button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -220,13 +220,13 @@ destinations beats the duplication.
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #aab;
|
color: var(--color-text-muted);
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tabs button.active {
|
.tabs button.active {
|
||||||
color: #e8eaf6;
|
color: var(--color-text);
|
||||||
background: #1c2238;
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
.tabs .icon {
|
.tabs .icon {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
@@ -240,12 +240,12 @@ destinations beats the duplication.
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #14182a;
|
background: var(--color-surface-overlay);
|
||||||
color: #e8eaf6;
|
color: var(--color-text);
|
||||||
border-top: 1px solid #2a3150;
|
border-top: 1px solid var(--color-border);
|
||||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
.drawer > button,
|
.drawer > button,
|
||||||
.drawer > details > summary {
|
.drawer > details > summary {
|
||||||
@@ -259,7 +259,7 @@ destinations beats the duplication.
|
|||||||
}
|
}
|
||||||
.drawer > button:hover,
|
.drawer > button:hover,
|
||||||
.drawer > details > summary:hover {
|
.drawer > details > summary:hover {
|
||||||
background: #1c2238;
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
.drawer > details > summary {
|
.drawer > details > summary {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -279,7 +279,7 @@ destinations beats the duplication.
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
border-left: 1px solid #2a3150;
|
border-left: 1px solid var(--color-border);
|
||||||
margin: 0 0.5rem 0.25rem;
|
margin: 0 0.5rem 0.25rem;
|
||||||
}
|
}
|
||||||
.sub > button {
|
.sub > button {
|
||||||
@@ -292,6 +292,6 @@ destinations beats the duplication.
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.sub > button:hover {
|
.sub > button:hover {
|
||||||
background: #1c2238;
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ through the binding without extra plumbing.
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 18rem;
|
width: 18rem;
|
||||||
min-width: 18rem;
|
min-width: 18rem;
|
||||||
background: #0e1322;
|
background: var(--color-surface);
|
||||||
color: #e8eaf6;
|
color: var(--color-text);
|
||||||
border-left: 1px solid #20253a;
|
border-left: 1px solid var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
.head {
|
.head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -130,7 +130,7 @@ through the binding without extra plumbing.
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.close:hover {
|
.close:hover {
|
||||||
color: #6d8cff;
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -51,24 +51,24 @@ flips it on.
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.5rem 0.5rem 0;
|
padding: 0.5rem 0.5rem 0;
|
||||||
border-bottom: 1px solid #20253a;
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
font-family: system-ui, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
.tab-bar button {
|
.tab-bar button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #aab;
|
color: var(--color-text-muted);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tab-bar button.active {
|
.tab-bar button.active {
|
||||||
color: #e8eaf6;
|
color: var(--color-text);
|
||||||
border-bottom-color: #6d8cff;
|
border-bottom-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.tab-bar button:hover:not(.active) {
|
.tab-bar button:hover:not(.active) {
|
||||||
color: #e8eaf6;
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<script lang="ts">
|
||||||
|
import "$lib/theme/tokens.css";
|
||||||
|
import "$lib/theme/base.css";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -99,7 +101,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.status {
|
.status {
|
||||||
padding: 2rem;
|
padding: var(--space-6);
|
||||||
font-family: system-ui, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -594,8 +594,8 @@ fresh.
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0a0e1a;
|
background: var(--color-bg);
|
||||||
color: #e8eaf6;
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.body {
|
.body {
|
||||||
flex: 1;
|
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