Skip to content

Commit 7332127

Browse files
Kosthiclaude
andcommitted
feat: add light/dark theme toggle with warm cream light mode
Implement a complete theme system with dark/light mode switching: - Create theme-tokens.css as single source of truth for all theme colors - Add FOUC prevention script in BaseLayout head for instant theme load - Add theme toggle button (sun/moon icons) to NavBar desktop and mobile - Replace 50+ hardcoded dark rgba values in global.css with CSS variables - Add comprehensive [data-theme="light"] overrides for all components: panel, section, cards, timeline, lightbox, footer, prose code blocks, hero title, contact QR codes, social links, etc. - Support multi-tab sync via storage event and window.__rushdbTheme API - Light palette uses warm cream (#fdf6e3) with brown-toned text/borders - Use mix-blend-mode: multiply for QR images to blend with cream bg - Add i18n strings for theme toggle in zh/en/ja Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 060ae89 commit 7332127

File tree

9 files changed

+780
-143
lines changed

9 files changed

+780
-143
lines changed

src/components/blog/PostCard.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ const heroImage = post.data.heroImage;
7979

8080
<style>
8181
.post-card {
82-
background: rgba(10, 14, 32, 0.62);
83-
border: 1px solid rgba(240, 246, 255, 0.12);
82+
background: var(--surface);
83+
border: 1px solid var(--line);
8484
border-radius: 18px;
8585
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
8686
overflow: hidden;

src/components/blog/RelatedPosts.astro

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ function formatDate(date: Date): string {
115115
.related-posts {
116116
margin-top: 4rem;
117117
padding-top: 3rem;
118-
border-top: 1px solid rgba(240, 246, 255, 0.1);
118+
border-top: 1px solid var(--line);
119119
}
120120

121121
.related-posts-title {
@@ -149,8 +149,8 @@ function formatDate(date: Date): string {
149149
display: flex;
150150
flex-direction: column;
151151
padding: 1.25rem;
152-
background: rgba(10, 14, 32, 0.5);
153-
border: 1px solid rgba(240, 246, 255, 0.1);
152+
background: var(--surface);
153+
border: 1px solid var(--line);
154154
border-radius: 14px;
155155
text-decoration: none;
156156
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);

src/components/blog/TableOfContents.astro

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ const filteredHeadings = headings.filter(h => h.depth <= 3);
140140
.toc {
141141
position: sticky;
142142
top: 100px;
143-
background: rgba(10, 14, 32, 0.62);
144-
border: 1px solid rgba(240, 246, 255, 0.12);
143+
background: var(--surface);
144+
border: 1px solid var(--line);
145145
border-radius: 14px;
146146
padding: 1.25rem;
147147
backdrop-filter: blur(12px);
@@ -155,7 +155,7 @@ const filteredHeadings = headings.filter(h => h.depth <= 3);
155155
text-transform: uppercase;
156156
letter-spacing: 0.05em;
157157
padding-bottom: 0.75rem;
158-
border-bottom: 1px solid rgba(240, 246, 255, 0.1);
158+
border-bottom: 1px solid var(--line);
159159
}
160160

161161
.toc-list {
@@ -209,7 +209,7 @@ const filteredHeadings = headings.filter(h => h.depth <= 3);
209209
width: 6px;
210210
height: 6px;
211211
border-radius: 50%;
212-
background: rgba(240, 246, 255, 0.2);
212+
background: var(--line);
213213
flex-shrink: 0;
214214
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
215215
}

src/components/vue/NavBar.vue

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ const t = (key: string) => {
2323
2424
const isLanguageOpen = ref(false);
2525
const isMenuOpen = ref(false);
26-
const navbarBg = ref('rgba(5, 7, 18, 0.72)');
26+
const isScrolled = ref(false);
2727
const activeSection = ref('');
28+
const theme = ref<'dark' | 'light'>('dark');
2829
2930
const languageLabel = computed(() => t(`language.${props.lang}`));
3031
@@ -106,7 +107,32 @@ function onLogoClick(event: MouseEvent) {
106107
}
107108
108109
function updateNavbarBg() {
109-
navbarBg.value = window.scrollY > 100 ? 'rgba(5, 7, 18, 0.82)' : 'rgba(5, 7, 18, 0.72)';
110+
isScrolled.value = window.scrollY > 100;
111+
}
112+
113+
function toggleTheme() {
114+
const w = window as any;
115+
if (w.__rushdbTheme) {
116+
w.__rushdbTheme.toggle();
117+
}
118+
}
119+
120+
function syncThemeState() {
121+
const current = document.documentElement.getAttribute('data-theme') || 'dark';
122+
theme.value = current as 'dark' | 'light';
123+
}
124+
125+
function onThemeChange() {
126+
syncThemeState();
127+
}
128+
129+
function onStorageChange(e: StorageEvent) {
130+
if (e.key !== 'rushdb-theme') return;
131+
const w = window as any;
132+
if (w.__rushdbTheme && e.newValue) {
133+
w.__rushdbTheme.set(e.newValue, false);
134+
}
135+
syncThemeState();
110136
}
111137
112138
function onKeydown(e: KeyboardEvent) {
@@ -174,15 +200,20 @@ function setupNavHighlightObserver() {
174200
}
175201
176202
onMounted(() => {
203+
syncThemeState();
177204
updateNavbarBg();
178205
window.addEventListener('scroll', updateNavbarBg, { passive: true });
206+
window.addEventListener('rushdb-theme-change', onThemeChange);
207+
window.addEventListener('storage', onStorageChange);
179208
document.addEventListener('keydown', onKeydown);
180209
document.addEventListener('click', onDocumentClick);
181210
setupNavHighlightObserver();
182211
});
183212
184213
onUnmounted(() => {
185214
window.removeEventListener('scroll', updateNavbarBg);
215+
window.removeEventListener('rushdb-theme-change', onThemeChange);
216+
window.removeEventListener('storage', onStorageChange);
186217
document.removeEventListener('keydown', onKeydown);
187218
document.removeEventListener('click', onDocumentClick);
188219
navHighlightObserver?.disconnect();
@@ -191,7 +222,7 @@ onUnmounted(() => {
191222
</script>
192223

193224
<template>
194-
<nav class="navbar" :class="{ 'menu-open': isMenuOpen }" :style="{ background: navbarBg }" role="navigation" :aria-label="t('a11y.mainNavigation')">
225+
<nav class="navbar" :class="{ 'menu-open': isMenuOpen, 'is-scrolled': isScrolled }" role="navigation" :aria-label="t('a11y.mainNavigation')">
195226
<div class="nav-content">
196227
<a :href="homePath" class="nav-logo" @click="onLogoClick">
197228
<img src="/RushDB.png" alt="RushDB Logo" class="nav-logo-img" />
@@ -224,6 +255,20 @@ onUnmounted(() => {
224255
</li>
225256
</ul>
226257

258+
<button
259+
class="theme-toggle"
260+
type="button"
261+
:aria-label="theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')"
262+
@click="toggleTheme"
263+
>
264+
<svg v-if="theme === 'dark'" class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
265+
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
266+
</svg>
267+
<svg v-else class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
268+
<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
269+
</svg>
270+
</button>
271+
227272
<div class="language-switcher" :class="{ show: isLanguageOpen }">
228273
<div class="language-trigger" @click="toggleLanguageSwitcher">{{ languageLabel }}</div>
229274
<div class="language-dropdown">
@@ -294,6 +339,19 @@ onUnmounted(() => {
294339
</ul>
295340

296341
<div class="nav-mobile-actions">
342+
<button
343+
class="theme-toggle"
344+
type="button"
345+
:aria-label="theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')"
346+
@click="toggleTheme"
347+
>
348+
<svg v-if="theme === 'dark'" class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
349+
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
350+
</svg>
351+
<svg v-else class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
352+
<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
353+
</svg>
354+
</button>
297355
<div class="language-switcher mobile-lang" :class="{ show: isLanguageOpen }">
298356
<div class="language-trigger" @click="toggleLanguageSwitcher">{{ languageLabel }}</div>
299357
<div class="language-dropdown">

src/i18n/ui.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const ui = {
2020
blog: '博客',
2121
github: 'View Our Github',
2222
openMenu: '打开菜单',
23+
switchToLight: '切换到浅色模式',
24+
switchToDark: '切换到深色模式',
2325
},
2426
hero: {
2527
title: 'RushDB 无限进步',
@@ -226,6 +228,8 @@ export const ui = {
226228
blog: 'Blog',
227229
github: 'View Our Github',
228230
openMenu: 'Open menu',
231+
switchToLight: 'Switch to Light Mode',
232+
switchToDark: 'Switch to Dark Mode',
229233
},
230234
hero: {
231235
title: 'RushDB, Unlimited Progress',
@@ -432,6 +436,8 @@ export const ui = {
432436
blog: 'ブログ',
433437
github: 'View Our Github',
434438
openMenu: 'メニューを開く',
439+
switchToLight: 'ライトモードに切替',
440+
switchToDark: 'ダークモードに切替',
435441
},
436442
hero: {
437443
title: 'RushDB、無限の進歩',

src/layouts/BaseLayout.astro

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ const metaDescription = description || descriptions[lang];
5858
<link rel="icon" type="image/png" href="/RushDB.png" />
5959
<link rel="shortcut icon" type="image/png" href="/RushDB.png" />
6060
<link rel="apple-touch-icon" href="/RushDB.png" />
61+
<meta name="theme-color" content="#050712" media="(prefers-color-scheme: dark)" />
62+
<meta name="theme-color" content="#fdf6e3" media="(prefers-color-scheme: light)" />
6163
<link
6264
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap"
6365
rel="stylesheet"
@@ -85,6 +87,61 @@ const metaDescription = description || descriptions[lang];
8587
<link rel="alternate" hreflang="en" href="https://rushdb-lab.github.io/en/" />
8688
<link rel="alternate" hreflang="ja" href="https://rushdb-lab.github.io/ja/" />
8789
<link rel="alternate" hreflang="x-default" href="https://rushdb-lab.github.io/zh/" />
90+
91+
<!-- Theme initialization (FOUC prevention - must run before first paint) -->
92+
<script is:inline>
93+
(function() {
94+
var KEY = 'rushdb-theme';
95+
var ALLOWED = { dark: 1, light: 1 };
96+
var root = document.documentElement;
97+
98+
function sanitize(v) { return typeof v === 'string' && ALLOWED[v] ? v : null; }
99+
function readStorage() { try { return sanitize(localStorage.getItem(KEY)); } catch(e) { return null; } }
100+
101+
var media = window.matchMedia('(prefers-color-scheme: dark)');
102+
var systemTheme = media.matches ? 'dark' : 'light';
103+
var stored = readStorage();
104+
var resolved = stored || systemTheme;
105+
106+
root.setAttribute('data-theme', resolved);
107+
root.style.colorScheme = resolved;
108+
root.setAttribute('data-theme-source', stored ? 'user' : 'system');
109+
root.classList.add('theme-preload');
110+
111+
window.__rushdbTheme = {
112+
get: function() { return root.getAttribute('data-theme') || 'dark'; },
113+
set: function(next, persist) {
114+
var safe = sanitize(next);
115+
if (!safe) return;
116+
root.setAttribute('data-theme', safe);
117+
root.style.colorScheme = safe;
118+
root.setAttribute('data-theme-source', persist !== false ? 'user' : 'system');
119+
try {
120+
if (persist !== false) localStorage.setItem(KEY, safe);
121+
else localStorage.removeItem(KEY);
122+
} catch(e) {}
123+
window.dispatchEvent(new CustomEvent('rushdb-theme-change', { detail: { theme: safe } }));
124+
},
125+
toggle: function() {
126+
var current = root.getAttribute('data-theme') || 'dark';
127+
this.set(current === 'dark' ? 'light' : 'dark', true);
128+
}
129+
};
130+
131+
var onMediaChange = function(e) {
132+
if (root.getAttribute('data-theme-source') === 'user') return;
133+
var t = e.matches ? 'dark' : 'light';
134+
root.setAttribute('data-theme', t);
135+
root.style.colorScheme = t;
136+
window.dispatchEvent(new CustomEvent('rushdb-theme-change', { detail: { theme: t } }));
137+
};
138+
if (media.addEventListener) {
139+
media.addEventListener('change', onMediaChange);
140+
} else if (media.addListener) {
141+
media.addListener(onMediaChange);
142+
}
143+
})();
144+
</script>
88145
</head>
89146
<body>
90147
<a href="#main-content" class="skip-link">{t('a11y.skipToContent')}</a>
@@ -222,6 +279,7 @@ const metaDescription = description || descriptions[lang];
222279
}, { passive: true });
223280

224281
document.addEventListener('DOMContentLoaded', () => {
282+
requestAnimationFrame(() => document.documentElement.classList.remove('theme-preload'));
225283
updateScrollProgress();
226284
setupMousePositionEffect();
227285
setupSectionObserver();

src/pages/[lang]/blog/[slug].astro

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ const formattedDate = post.data.pubDate.toLocaleDateString(
160160
}
161161

162162
.post-content {
163-
background: rgba(10, 14, 32, 0.62);
164-
border: 1px solid rgba(240, 246, 255, 0.12);
163+
background: var(--surface);
164+
border: 1px solid var(--line);
165165
border-radius: 18px;
166166
padding: 2rem;
167167
}
@@ -177,7 +177,7 @@ const formattedDate = post.data.pubDate.toLocaleDateString(
177177

178178
.post-content :global(h2) {
179179
font-size: 1.5rem;
180-
border-bottom: 1px solid rgba(240, 246, 255, 0.1);
180+
border-bottom: 1px solid var(--line);
181181
padding-bottom: 0.5rem;
182182
}
183183

@@ -199,15 +199,15 @@ const formattedDate = post.data.pubDate.toLocaleDateString(
199199
}
200200

201201
.post-content :global(code) {
202-
background: rgba(240, 246, 255, 0.08);
202+
background: var(--line-2, rgba(240, 246, 255, 0.08));
203203
padding: 0.2rem 0.4rem;
204204
border-radius: 4px;
205205
font-size: 0.9em;
206206
}
207207

208208
.post-content :global(pre) {
209-
background: rgba(10, 14, 32, 0.9) !important;
210-
border: 1px solid rgba(240, 246, 255, 0.1);
209+
background: var(--bg-primary) !important;
210+
border: 1px solid var(--line);
211211
border-radius: 12px;
212212
padding: 1rem;
213213
overflow-x: auto;
@@ -227,13 +227,13 @@ const formattedDate = post.data.pubDate.toLocaleDateString(
227227

228228
.post-content :global(th),
229229
.post-content :global(td) {
230-
border: 1px solid rgba(240, 246, 255, 0.1);
230+
border: 1px solid var(--line);
231231
padding: 0.75rem;
232232
text-align: left;
233233
}
234234

235235
.post-content :global(th) {
236-
background: rgba(240, 246, 255, 0.05);
236+
background: var(--line-2, rgba(240, 246, 255, 0.05));
237237
color: var(--text);
238238
}
239239

0 commit comments

Comments
 (0)