Skip to content

Commit d39ea77

Browse files
committed
Support dark theme
1 parent b400559 commit d39ea77

11 files changed

Lines changed: 340 additions & 22 deletions

File tree

src/components/games/GameCard.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const { title, slug, summary, status, components, locale, cover } = Astro.props;
1616
---
1717

1818
<a href={localizedPath(locale, `/games/${slug}`)} class="game-card">
19-
<div class="game-card__cover">
19+
<div class="game-card__cover themed-image">
2020
{cover ? (
2121
<img src={cover} alt={title} class="game-card__image" loading="lazy" />
2222
) : (

src/components/layout/BaseLayout.astro

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ const zhUrl = new URL(toggleLocalePath(Astro.url.pathname, "zh"), Astro.site).hr
4343
<title>{pageTitle}</title>
4444
<meta name="description" content={pageDescription} />
4545

46+
{/* No-FOUC theme init — MUST run before any stylesheet + before paint.
47+
Stored values: "light" / "dark" / missing.
48+
Missing = follow OS preference; "light"/"dark" = explicit override. */}
49+
<script is:inline>
50+
(function () {
51+
try {
52+
var stored = localStorage.getItem("ts-theme");
53+
if (stored === "dark") {
54+
document.documentElement.dataset.theme = "dark";
55+
} else if (stored === "light") {
56+
document.documentElement.dataset.theme = "light";
57+
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
58+
document.documentElement.dataset.theme = "dark";
59+
}
60+
} catch (e) {}
61+
})();
62+
</script>
63+
4664
<!-- Favicon -->
4765
<link rel="icon" type="image/png" href="/favicon.png" />
4866

src/components/layout/Header.astro

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
---
2+
import ThemeToggle from "./ThemeToggle.astro";
23
import { t, getLocaleFromUrl, localizedPath, toggleLocalePath, type Locale } from "../../lib/i18n";
34
45
interface Props {
@@ -22,7 +23,17 @@ const zhHref = toggleLocalePath(currentPath, "zh");
2223
<header class="header">
2324
<div class="container header__inner">
2425
<a href={localizedPath(locale, "/")} class="header__logo">
25-
<img src="/assets/brand/logo-horizontal-text-black.png" alt="Trace Studio" class="header__logo-img" />
26+
<img
27+
src="/assets/brand/logo-horizontal-text-black.png"
28+
alt="Trace Studio"
29+
class="header__logo-img header__logo-img--light"
30+
/>
31+
<img
32+
src="/assets/brand/logo-horizontal-text-white.png"
33+
alt="Trace Studio"
34+
class="header__logo-img header__logo-img--dark"
35+
aria-hidden="true"
36+
/>
2637
</a>
2738

2839
<nav class="header__nav" aria-label="Main navigation">
@@ -45,18 +56,22 @@ const zhHref = toggleLocalePath(currentPath, "zh");
4556
</ul>
4657
</nav>
4758

48-
<div class="header__locale font-mono">
49-
{locale === "en" ? (
50-
<span class="header__locale-active">{t(locale, "nav.locale.en")}</span>
51-
) : (
52-
<a href={enHref} class="header__locale-link">{t(locale, "nav.locale.en")}</a>
53-
)}
54-
<span class="header__locale-separator">/</span>
55-
{locale === "zh" ? (
56-
<span class="header__locale-active">{t(locale, "nav.locale.zh")}</span>
57-
) : (
58-
<a href={zhHref} class="header__locale-link">{t(locale, "nav.locale.zh")}</a>
59-
)}
59+
<div class="header__actions">
60+
<ThemeToggle locale={locale} />
61+
<span class="header__divider" aria-hidden="true"></span>
62+
<div class="header__locale font-mono">
63+
{locale === "en" ? (
64+
<span class="header__locale-active">{t(locale, "nav.locale.en")}</span>
65+
) : (
66+
<a href={enHref} class="header__locale-link">{t(locale, "nav.locale.en")}</a>
67+
)}
68+
<span class="header__locale-separator">/</span>
69+
{locale === "zh" ? (
70+
<span class="header__locale-active">{t(locale, "nav.locale.zh")}</span>
71+
) : (
72+
<a href={zhHref} class="header__locale-link">{t(locale, "nav.locale.zh")}</a>
73+
)}
74+
</div>
6075
</div>
6176
</div>
6277
</header>
@@ -90,6 +105,32 @@ const zhHref = toggleLocalePath(currentPath, "zh");
90105
width: auto;
91106
}
92107

108+
/* Dual-logo swap: light logo shows by default, dark logo under
109+
html[data-theme="dark"]. Both are loaded but only one is painted. */
110+
.header__logo-img--dark {
111+
display: none;
112+
}
113+
114+
html[data-theme="dark"] .header__logo-img--light {
115+
display: none;
116+
}
117+
118+
html[data-theme="dark"] .header__logo-img--dark {
119+
display: block;
120+
}
121+
122+
.header__actions {
123+
display: flex;
124+
align-items: center;
125+
gap: var(--space-3);
126+
}
127+
128+
.header__divider {
129+
width: 1px;
130+
height: 1rem;
131+
background: var(--c-gray-300);
132+
}
133+
93134
.header__nav-list {
94135
display: flex;
95136
gap: var(--space-6);
@@ -151,7 +192,8 @@ const zhHref = toggleLocalePath(currentPath, "zh");
151192
gap: var(--space-3);
152193
}
153194

154-
.header__locale {
195+
.header__locale,
196+
.header__divider {
155197
display: none;
156198
}
157199
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
---
2+
import { t, getLocaleFromUrl, type Locale } from "../../lib/i18n";
3+
4+
interface Props {
5+
locale?: Locale;
6+
}
7+
8+
const locale = Astro.props.locale ?? getLocaleFromUrl(Astro.url);
9+
---
10+
11+
<button
12+
type="button"
13+
class="theme-toggle font-mono"
14+
aria-label={t(locale, "theme.toggle.aria")}
15+
aria-pressed="false"
16+
data-theme-toggle
17+
>
18+
<span class="theme-toggle__icon theme-toggle__icon--sun" aria-hidden="true">☀</span>
19+
<span class="theme-toggle__sep" aria-hidden="true">/</span>
20+
<span class="theme-toggle__icon theme-toggle__icon--moon" aria-hidden="true">☾</span>
21+
</button>
22+
23+
<script>
24+
// ThemeToggle — binary light/dark switch with persistence.
25+
//
26+
// Behavior:
27+
// - On first visit (no localStorage "ts-theme"), the page follows OS
28+
// preference, and this script also listens to OS changes live.
29+
// - Once the user clicks the toggle, we commit to an explicit value
30+
// ("light" or "dark") in localStorage and stop listening to OS.
31+
//
32+
// The initial `data-theme` on <html> is set by the no-FOUC script in
33+
// BaseLayout; this script only handles click + live OS updates.
34+
35+
const STORAGE_KEY = "ts-theme";
36+
const root = document.documentElement;
37+
38+
function currentTheme(): "light" | "dark" {
39+
if (root.dataset.theme === "dark") return "dark";
40+
if (root.dataset.theme === "light") return "light";
41+
// data-theme attribute missing → fall back to resolved OS preference
42+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
43+
}
44+
45+
function applyTheme(theme: "light" | "dark") {
46+
root.dataset.theme = theme;
47+
document.querySelectorAll<HTMLButtonElement>("[data-theme-toggle]").forEach((btn) => {
48+
btn.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
49+
});
50+
window.dispatchEvent(new CustomEvent("theme-change", { detail: { theme } }));
51+
}
52+
53+
// Sync aria-pressed on load (data-theme already set by no-FOUC script)
54+
applyTheme(currentTheme());
55+
56+
document.querySelectorAll<HTMLButtonElement>("[data-theme-toggle]").forEach((btn) => {
57+
btn.addEventListener("click", () => {
58+
const next = currentTheme() === "dark" ? "light" : "dark";
59+
applyTheme(next);
60+
try {
61+
localStorage.setItem(STORAGE_KEY, next);
62+
} catch (e) {
63+
/* noop */
64+
}
65+
});
66+
});
67+
68+
// Live OS change listener — only active while user has NOT made an
69+
// explicit choice (no value in localStorage).
70+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
71+
mq.addEventListener("change", (ev) => {
72+
let stored: string | null = null;
73+
try {
74+
stored = localStorage.getItem(STORAGE_KEY);
75+
} catch (e) {
76+
/* noop */
77+
}
78+
if (stored === "light" || stored === "dark") return;
79+
applyTheme(ev.matches ? "dark" : "light");
80+
});
81+
</script>
82+
83+
<style>
84+
.theme-toggle {
85+
display: inline-flex;
86+
align-items: center;
87+
gap: var(--space-1);
88+
font-size: var(--text-xs);
89+
padding: 0;
90+
color: var(--c-gray-400);
91+
background: none;
92+
border: none;
93+
cursor: pointer;
94+
line-height: 1;
95+
}
96+
97+
.theme-toggle:focus-visible {
98+
outline: 2px solid var(--c-ink);
99+
outline-offset: 3px;
100+
}
101+
102+
.theme-toggle__icon {
103+
color: var(--c-gray-400);
104+
transition: color var(--transition-fast);
105+
font-size: 0.95rem;
106+
}
107+
108+
.theme-toggle__sep {
109+
color: var(--c-gray-300);
110+
}
111+
112+
/* Active glyph matches current mode */
113+
html[data-theme="dark"] .theme-toggle__icon--moon,
114+
html:not([data-theme="dark"]) .theme-toggle__icon--sun {
115+
color: var(--c-ink);
116+
}
117+
118+
.theme-toggle:hover .theme-toggle__icon {
119+
color: var(--c-ink);
120+
}
121+
</style>

src/i18n/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
"nav.locale.en": "EN",
1010
"nav.locale.zh": "",
1111

12+
"theme.toggle.aria": "Toggle light/dark theme",
13+
"theme.toggle.light": "Light",
14+
"theme.toggle.dark": "Dark",
15+
1216
"hero.tagline": "Building open-source game engines, solvers, and research.",
1317
"hero.cta": "Explore on GitHub",
1418

src/i18n/zh.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
"nav.locale.en": "EN",
1010
"nav.locale.zh": "",
1111

12+
"theme.toggle.aria": "切换明暗主题",
13+
"theme.toggle.light": "明亮",
14+
"theme.toggle.dark": "暗色",
15+
1216
"hero.tagline": "构建开源的游戏引擎、求解器与相关研究。",
1317
"hero.cta": "在 GitHub 上浏览",
1418

src/pages/games/[slug].astro

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ const related = allResearch.filter(
5757
</div>
5858
{game.data.cover && (
5959
<div class="game-header__cover">
60-
<img src={game.data.cover} alt={game.data.title} class="game-header__image" />
60+
<div class="game-header__image-wrap themed-image">
61+
<img src={game.data.cover} alt={game.data.title} class="game-header__image" />
62+
</div>
6163
</div>
6264
)}
6365
</div>
@@ -106,11 +108,16 @@ const related = allResearch.filter(
106108
padding-top: var(--space-16);
107109
}
108110

111+
.game-header__image-wrap {
112+
overflow: hidden;
113+
border: var(--guideline-width) solid var(--guideline-color);
114+
background: var(--c-gray-50);
115+
line-height: 0;
116+
}
117+
109118
.game-header__image {
110119
width: 100%;
111120
height: auto;
112-
border: var(--guideline-width) solid var(--guideline-color);
113-
background: var(--c-gray-50);
114121
}
115122

116123
.game-meta {

src/pages/zh/games/[slug].astro

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ const related = allResearch.filter(
5757
</div>
5858
{game.data.cover && (
5959
<div class="game-header__cover">
60-
<img src={game.data.cover} alt={game.data.title} class="game-header__image" />
60+
<div class="game-header__image-wrap themed-image">
61+
<img src={game.data.cover} alt={game.data.title} class="game-header__image" />
62+
</div>
6163
</div>
6264
)}
6365
</div>
@@ -106,11 +108,16 @@ const related = allResearch.filter(
106108
padding-top: var(--space-16);
107109
}
108110

111+
.game-header__image-wrap {
112+
overflow: hidden;
113+
border: var(--guideline-width) solid var(--guideline-color);
114+
background: var(--c-gray-50);
115+
line-height: 0;
116+
}
117+
109118
.game-header__image {
110119
width: 100%;
111120
height: auto;
112-
border: var(--guideline-width) solid var(--guideline-color);
113-
background: var(--c-gray-50);
114121
}
115122

116123
.game-meta {

src/styles/global.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@import "./tokens.css";
22
@import "./typography.css";
3+
@import "./themes.css";
34

45
/* ── Reset ── */
56
*,

0 commit comments

Comments
 (0)