Skip to content

Commit 8aaca5d

Browse files
AIwork4meclaude
andcommitted
feat: theme infrastructure — ThemeProvider, darkMode class, anti-flash script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 36611e7 commit 8aaca5d

4 files changed

Lines changed: 130 additions & 4 deletions

File tree

platform/src/app/[locale]/layout.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { notFound } from 'next/navigation'
66
import '../globals.css'
77
import { routing } from '@/i18n/routing'
88
import BackToTop from '@/components/BackToTop'
9+
import { ThemeProvider } from '@/components/ThemeProvider'
910

1011
const notoSansSC = Noto_Sans_SC({
1112
subsets: ['latin'],
@@ -101,11 +102,20 @@ export default async function LocaleLayout({
101102

102103
return (
103104
<html lang={locale} className={notoSansSC.variable}>
105+
<head>
106+
<script
107+
dangerouslySetInnerHTML={{
108+
__html: `(function(){try{var t=localStorage.getItem('ceo-theme');if(t==='light'){document.documentElement.classList.remove('dark')}else if(t==='dark'||window.matchMedia('(prefers-color-scheme:dark)').matches){document.documentElement.classList.add('dark')}else{document.documentElement.classList.add('dark')}}catch(e){document.documentElement.classList.add('dark')}})()`,
109+
}}
110+
/>
111+
</head>
104112
<body className="font-sans antialiased">
105-
<NextIntlClientProvider messages={messages}>
106-
{children}
107-
<BackToTop />
108-
</NextIntlClientProvider>
113+
<ThemeProvider>
114+
<NextIntlClientProvider messages={messages}>
115+
{children}
116+
<BackToTop />
117+
</NextIntlClientProvider>
118+
</ThemeProvider>
109119
</body>
110120
</html>
111121
)

platform/src/app/globals.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ html {
66
scroll-behavior: smooth;
77
}
88

9+
/* Smooth theme transition */
10+
html.theme-transitioning,
11+
html.theme-transitioning *,
12+
html.theme-transitioning *::before,
13+
html.theme-transitioning *::after {
14+
transition: background-color 0.3s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease !important;
15+
}
16+
917
body {
1018
background-color: #0f0f0f;
1119
color: #E6E1E5;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use client'
2+
3+
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
4+
5+
type Theme = 'light' | 'dark'
6+
7+
interface ThemeContextType {
8+
theme: Theme
9+
toggleTheme: () => void
10+
}
11+
12+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
13+
14+
const THEME_STORAGE_KEY = 'ceo-theme'
15+
16+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
17+
const [theme, setTheme] = useState<Theme>('dark')
18+
const [mounted, setMounted] = useState(false)
19+
20+
useEffect(() => {
21+
setMounted(true)
22+
23+
// Check localStorage first, then system preference
24+
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null
25+
26+
if (savedTheme === 'light' || savedTheme === 'dark') {
27+
setTheme(savedTheme)
28+
} else {
29+
// Check system preference
30+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
31+
setTheme(prefersDark ? 'dark' : 'light')
32+
}
33+
}, [])
34+
35+
useEffect(() => {
36+
if (!mounted) return
37+
38+
const root = document.documentElement
39+
40+
if (theme === 'dark') {
41+
root.classList.add('dark')
42+
root.classList.remove('light')
43+
} else {
44+
root.classList.remove('dark')
45+
root.classList.add('light')
46+
}
47+
48+
localStorage.setItem(THEME_STORAGE_KEY, theme)
49+
}, [theme, mounted])
50+
51+
// Listen for system preference changes
52+
useEffect(() => {
53+
if (!mounted) return
54+
55+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
56+
57+
const handleChange = (e: MediaQueryListEvent) => {
58+
// Only update if user hasn't explicitly set a preference
59+
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY)
60+
if (!savedTheme) {
61+
setTheme(e.matches ? 'dark' : 'light')
62+
}
63+
}
64+
65+
mediaQuery.addEventListener('change', handleChange)
66+
return () => mediaQuery.removeEventListener('change', handleChange)
67+
}, [mounted])
68+
69+
const toggleTheme = useCallback(() => {
70+
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
71+
}, [])
72+
73+
// Prevent hydration mismatch by not rendering until mounted
74+
if (!mounted) {
75+
return <>{children}</>
76+
}
77+
78+
return (
79+
<ThemeContext.Provider value={{ theme, toggleTheme }}>
80+
{children}
81+
</ThemeContext.Provider>
82+
)
83+
}
84+
85+
export function useTheme() {
86+
const context = useContext(ThemeContext)
87+
if (context === undefined) {
88+
throw new Error('useTheme must be used within a ThemeProvider')
89+
}
90+
return context
91+
}

platform/tailwind.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Config } from 'tailwindcss'
22

33
const config: Config = {
4+
darkMode: 'class',
45
content: [
56
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
67
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
@@ -9,6 +10,22 @@ const config: Config = {
910
theme: {
1011
extend: {
1112
colors: {
13+
// Light mode tokens
14+
'light-surface-dim': '#DED8E1',
15+
'light-surface': '#FFFBFE',
16+
'light-surface-container': '#F3EDF7',
17+
'light-surface-high': '#ECE6F0',
18+
'light-surface-highest': '#E6E0E9',
19+
'light-primary': '#6750A4',
20+
'light-primary-container': '#EADDFF',
21+
'light-primary-on': '#21005D',
22+
'light-onsurface': '#1C1B1F',
23+
'light-onsurface-variant': '#49454F',
24+
'light-outline': '#79747E',
25+
'light-outline-variant': '#CAC4D0',
26+
'light-success': '#006C4E',
27+
'light-danger': '#BA1A1A',
28+
'light-warning': '#8B5000',
1229
// Legacy tokens — keep for other pages (auth, courses, dashboard, etc.)
1330
dark: {
1431
bg: '#0f0f0f',

0 commit comments

Comments
 (0)