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) {
>
+
}