diff --git a/static/style.css b/static/style.css index 7d0e703..f951f49 100644 --- a/static/style.css +++ b/static/style.css @@ -5,30 +5,32 @@ @source '../../internal/**/*.templ'; :root { - --bg: #fafaf9; - --panel: #f5f5f4; - --panel-soft: #e7e5e4; - --header: #ffffff; - --text: #1c1917; - --text-muted: #57534e; - --text-faint: #a8a29e; - --accent: #0c0a09; + color-scheme: light dark; + + --bg: light-dark(#fafaf9, #0c0a09); + --panel: light-dark(#f5f5f4, #1c1917); + --panel-soft: light-dark(#e7e5e4, #292524); + --header: light-dark(#ffffff, #1c1917); + --text: light-dark(#1c1917, #e7e5e4); + --text-muted: light-dark(#57534e, #a8a29e); + --text-faint: light-dark(#a8a29e, #78716c); + --accent: light-dark(#0c0a09, #fafaf9); --danger: #dc2626; - --surface-search: #f5f5f4; - --surface-search-focus-border: rgba(12, 10, 9, 0.12); - --surface-thumb: #e7e5e4; - --surface-input: #ffffff; - --surface-input-focus: #ffffff; - --surface-tab-hover: #e7e5e4; - --surface-tab-active: #1c1917; - --text-tab-active: #fafaf9; - --surface-select: #ffffff; - --surface-search-view-all: #f5f5f4; - --text-on-accent: #fafaf9; - --overlay-subtle: rgba(0, 0, 0, 0.04); - --shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.04); - --shadow-card: 0 4px 12px rgba(0, 0, 0, 0.08); - --shadow-card-hover: 0 8px 24px rgba(0, 0, 0, 0.12); + --surface-search: light-dark(#f5f5f4, #1c1917); + --surface-search-focus-border: light-dark(rgba(12, 10, 9, 0.12), rgba(255, 255, 255, 0.12)); + --surface-thumb: light-dark(#e7e5e4, #44403c); + --surface-input: light-dark(#ffffff, #1c1917); + --surface-input-focus: light-dark(#ffffff, #1c1917); + --surface-tab-hover: light-dark(#e7e5e4, #292524); + --surface-tab-active: light-dark(#1c1917, #fafaf9); + --text-tab-active: light-dark(#fafaf9, #0c0a09); + --surface-select: light-dark(#ffffff, #1c1917); + --surface-search-view-all: light-dark(#f5f5f4, #1c1917); + --text-on-accent: light-dark(#fafaf9, #0c0a09); + --overlay-subtle: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.04)); + --shadow-subtle: light-dark(0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.2)); + --shadow-card: light-dark(0 4px 12px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.3)); + --shadow-card-hover: light-dark(0 8px 24px rgba(0, 0, 0, 0.12), 0 8px 24px rgba(0, 0, 0, 0.4)); --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; @@ -40,3 +42,11 @@ --font: 'DM Sans', 'Segoe UI', system-ui, sans-serif; --radius: 6px; } + +[data-theme="light"] { + color-scheme: light; +} + +[data-theme="dark"] { + color-scheme: dark; +} diff --git a/static/theme.ts b/static/theme.ts new file mode 100644 index 0000000..50ed138 --- /dev/null +++ b/static/theme.ts @@ -0,0 +1,80 @@ +type Theme = 'system' | 'light' | 'dark' + +const STORAGE_KEY = 'theme' + +const getSavedTheme = (): Theme => { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw === 'light' || raw === 'dark') return raw + return 'system' +} + +const getEffectiveTheme = (theme: Theme): 'light' | 'dark' => { + if (theme !== 'system') return theme + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +const applyTheme = (theme: Theme): void => { + const html = document.documentElement + if (theme === 'system') { + html.removeAttribute('data-theme') + } else { + html.setAttribute('data-theme', theme) + } + localStorage.setItem(STORAGE_KEY, theme) + updateToggleButton(theme) +} + +const cycleTheme = (): void => { + const current = getSavedTheme() + const next: Theme = current === 'system' ? 'light' : current === 'light' ? 'dark' : 'system' + applyTheme(next) +} + +const updateToggleButton = (theme: Theme): void => { + const btn = document.getElementById('theme-toggle') + if (!btn) return + + const label = btn.querySelector('[data-theme-label]') as HTMLElement | null + if (label) { + label.textContent = theme + } + + const svg = btn.querySelector('svg') + if (!svg) return + + if (theme === 'light') { + svg.innerHTML = '' + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('fill', 'none') + } else if (theme === 'dark') { + svg.innerHTML = '' + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('fill', 'none') + } else { + svg.innerHTML = '' + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('fill', 'none') + } +} + +const initTheme = (): void => { + const saved = getSavedTheme() + applyTheme(saved) + + const btn = document.getElementById('theme-toggle') + if (btn) { + btn.addEventListener('click', cycleTheme) + } + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (getSavedTheme() === 'system') { + applyTheme('system') + } + }) +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initTheme) +} else { + initTheme() +} diff --git a/web/shared/layout/layout.templ b/web/shared/layout/layout.templ index 6c98c6c..1a81a7a 100644 --- a/web/shared/layout/layout.templ +++ b/web/shared/layout/layout.templ @@ -12,6 +12,11 @@ templ Layout(title string, showHeader bool) { + + @@ -77,6 +82,18 @@ templ Layout(title string, showHeader bool) { > + }