Skip to content

Commit ae98549

Browse files
committed
feat: proper theme switcher with system/light/dark modes
- Apply theme class to <html> so portaled elements (dialogs, selects, popovers) inherit the correct theme - Add 3-state theme dropdown: Light / Dark / System (auto-detect OS) - Persist theme choice in localStorage - Inline FOUC prevention script in index.html - Theme icons: Sun (light), Moon (dark), Monitor (system)
1 parent 1b2780c commit ae98549

6 files changed

Lines changed: 107 additions & 17 deletions

File tree

index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!doctype html>
2-
<html lang="en" data-theme="dark">
2+
<html lang="en" class="dark">
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -54,6 +54,12 @@
5454
</script>
5555
</head>
5656
<body>
57+
<script>
58+
(function(){var t=localStorage.getItem('glyph-theme')||'system';
59+
var r=t==='system'?window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light':t;
60+
document.documentElement.className=r;
61+
document.querySelector('meta[name=theme-color]').content=r==='dark'?'#000000':'#f5f5f5'})()
62+
</script>
5763
<div id="root"></div>
5864
<script type="module" src="/src/main.tsx"></script>
5965
</body>

src/App.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import { MobileDisclaimer } from '@/components/MobileDisclaimer'
99
import { ExportPopover } from '@/components/ExportPopover'
1010
import { KeyboardHelp } from '@/components/KeyboardHelp'
1111
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
12+
import { useThemeEffect } from '@/hooks/useThemeEffect'
1213

1314
function App() {
14-
const themeMode = useStore((s) => s.themeMode)
15+
useThemeEffect()
1516
const sidebarHidden = useStore((s) => s.sidebarHidden)
1617
const [showExport, setShowExport] = useState(false)
1718
const [showShortcuts, setShowShortcuts] = useState(false)
@@ -24,9 +25,7 @@ function App() {
2425
return (
2526
<ToastProvider>
2627
<DBInit />
27-
<main
28-
className={`flex h-full w-full ${themeMode === 'light' ? 'light' : ''}`}
29-
>
28+
<main className="flex h-full w-full">
3029
<AsciiCanvas />
3130
{!sidebarHidden && <Sidebar />}
3231
</main>

src/components/LeftModeButtons.tsx

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
1+
import { useState, useRef, useEffect } from 'react'
12
import { useStore } from '@/store/useStore'
23
import { Button } from '@/components/ui/button'
3-
import type { LeftPanel } from '@/types'
4+
import { Sun, Moon, Monitor } from 'lucide-react'
5+
import type { LeftPanel, ThemeMode } from '@/types'
46

57
const PANELS: { value: NonNullable<LeftPanel>; label: string }[] = [
68
{ value: 'library', label: 'Library' },
79
{ value: 'templates', label: 'Templates' },
810
{ value: 'creations', label: 'Creations' },
911
]
1012

13+
const THEME_OPTIONS: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
14+
{ value: 'light', label: 'Light', icon: Sun },
15+
{ value: 'dark', label: 'Dark', icon: Moon },
16+
{ value: 'system', label: 'System', icon: Monitor },
17+
]
18+
1119
export function LeftModeButtons() {
1220
const leftPanel = useStore((s) => s.leftPanel)
1321
const setLeftPanel = useStore((s) => s.setLeftPanel)
1422
const themeMode = useStore((s) => s.themeMode)
1523
const setThemeMode = useStore((s) => s.setThemeMode)
24+
const [open, setOpen] = useState(false)
25+
const menuRef = useRef<HTMLDivElement>(null)
26+
27+
useEffect(() => {
28+
if (!open) return
29+
const onClickOutside = (e: MouseEvent) => {
30+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false)
31+
}
32+
document.addEventListener('mousedown', onClickOutside)
33+
return () => document.removeEventListener('mousedown', onClickOutside)
34+
}, [open])
35+
36+
const ActiveIcon = THEME_OPTIONS.find((o) => o.value === themeMode)?.icon ?? Monitor
1637

1738
return (
1839
<div className="absolute top-3 left-3 z-10 flex items-center gap-1.5">
@@ -28,15 +49,37 @@ export function LeftModeButtons() {
2849
{p.label}
2950
</Button>
3051
))}
31-
<Button
32-
variant="ghost"
33-
size="xs"
34-
className="cursor-crosshair"
35-
onClick={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
36-
title={`Switch to ${themeMode === 'dark' ? 'light' : 'dark'} mode`}
37-
>
38-
{themeMode === 'dark' ? '\u2600' : '\u263E'}
39-
</Button>
52+
53+
<div className="relative" ref={menuRef}>
54+
<Button
55+
variant="ghost"
56+
size="xs"
57+
className="cursor-crosshair"
58+
onClick={() => setOpen(!open)}
59+
title="Theme"
60+
>
61+
<ActiveIcon className="h-3.5 w-3.5" />
62+
</Button>
63+
64+
{open && (
65+
<div className="absolute top-full left-0 mt-1 rounded-md border border-border bg-popover p-1 shadow-md min-w-[120px]">
66+
{THEME_OPTIONS.map((opt) => (
67+
<button
68+
key={opt.value}
69+
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs cursor-crosshair transition-colors ${
70+
themeMode === opt.value
71+
? 'bg-accent text-accent-foreground'
72+
: 'text-popover-foreground hover:bg-muted'
73+
}`}
74+
onClick={() => { setThemeMode(opt.value); setOpen(false) }}
75+
>
76+
<opt.icon className="h-3.5 w-3.5" />
77+
{opt.label}
78+
</button>
79+
))}
80+
</div>
81+
)}
82+
</div>
4083
</div>
4184
)
4285
}

src/hooks/useThemeEffect.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useEffect } from 'react'
2+
import { useStore } from '@/store/useStore'
3+
4+
const THEME_KEY = 'glyph-theme'
5+
const THEME_COLORS = { dark: '#000000', light: '#f5f5f5' }
6+
7+
function getSystemTheme(): 'dark' | 'light' {
8+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
9+
}
10+
11+
function applyTheme(resolved: 'dark' | 'light') {
12+
const root = document.documentElement
13+
root.classList.remove('dark', 'light')
14+
root.classList.add(resolved)
15+
16+
const meta = document.querySelector('meta[name="theme-color"]')
17+
if (meta) meta.setAttribute('content', THEME_COLORS[resolved])
18+
}
19+
20+
export function useThemeEffect() {
21+
const themeMode = useStore((s) => s.themeMode)
22+
23+
useEffect(() => {
24+
const resolved = themeMode === 'system' ? getSystemTheme() : themeMode
25+
applyTheme(resolved)
26+
localStorage.setItem(THEME_KEY, themeMode)
27+
28+
if (themeMode !== 'system') return
29+
30+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
31+
const onChange = () => applyTheme(getSystemTheme())
32+
mq.addEventListener('change', onChange)
33+
return () => mq.removeEventListener('change', onChange)
34+
}, [themeMode])
35+
}
36+
37+
export function loadPersistedTheme(): 'dark' | 'light' | 'system' {
38+
const stored = localStorage.getItem(THEME_KEY)
39+
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored
40+
return 'system'
41+
}

src/store/useStore.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
savePresetItem,
3232
deletePresetItem,
3333
} from '@/lib/db'
34+
import { loadPersistedTheme } from '@/hooks/useThemeEffect'
3435

3536
function uid(): string {
3637
return crypto.randomUUID()
@@ -195,7 +196,7 @@ export const useStore = create<AppState>((set, get) => ({
195196
fps: 30,
196197

197198
leftPanel: null,
198-
themeMode: 'dark',
199+
themeMode: loadPersistedTheme(),
199200
sidebarHidden: false,
200201

201202
galleryAssets: [],

src/types/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type SourceQuality = 320 | 480 | 720
2222

2323
export type LeftPanel = 'library' | 'templates' | 'creations' | null
2424

25-
export type ThemeMode = 'dark' | 'light'
25+
export type ThemeMode = 'dark' | 'light' | 'system'
2626

2727
export type HalftoneShape = 'circle' | 'square' | 'diamond' | 'pentagon' | 'hexagon'
2828

0 commit comments

Comments
 (0)