From 44a36e3fb7ca09d20aebc008ac7356a8e561d120 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 25 May 2026 01:15:50 +0200 Subject: [PATCH] feat: improve theme system with cookie and prefers-color-scheme --- static/theme.ts | 69 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/static/theme.ts b/static/theme.ts index 61bbc6c..d5d137d 100644 --- a/static/theme.ts +++ b/static/theme.ts @@ -1,18 +1,65 @@ type Theme = 'light' | 'dark'; const STORAGE_KEY = 'theme'; +const COOKIE_KEY = 'theme'; + +const getLocalStorage = (): Storage | null => { + try { + return window.localStorage; + } catch { + return null; + } +}; + +const readCookie = (key: string): string | null => { + const entries = document.cookie.split(';').map(part => part.trim()); + for (const entry of entries) { + if (!entry) continue; + const eqIndex = entry.indexOf('='); + if (eqIndex === -1) continue; + const k = entry.slice(0, eqIndex).trim(); + if (k !== key) continue; + return decodeURIComponent(entry.slice(eqIndex + 1)); + } + return null; +}; + +const writeCookie = (key: string, value: string): void => { + const maxAgeSeconds = 60 * 60 * 24 * 365; + document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(value)}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`; +}; + +const getPreferredTheme = (): Theme => { + const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false; + return prefersDark ? 'dark' : 'light'; +}; + +const normalizeTheme = (raw: string | null): Theme | null => { + if (raw === 'light' || raw === 'dark') return raw; + return null; +}; const getSavedTheme = (): Theme => { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw === 'light' || raw === 'dark') { - return raw; - } - return 'dark'; // default to dark + const storage = getLocalStorage(); + const fromStorage = normalizeTheme(storage?.getItem(STORAGE_KEY) ?? null); + if (fromStorage) return fromStorage; + + const fromCookie = normalizeTheme(readCookie(COOKIE_KEY)); + if (fromCookie) return fromCookie; + + return getPreferredTheme(); }; const applyTheme = (theme: Theme): void => { document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem(STORAGE_KEY, theme); + document.documentElement.style.colorScheme = theme; + const storage = getLocalStorage(); + try { + storage?.setItem(STORAGE_KEY, theme); + } catch { + // ignore + } + writeCookie(COOKIE_KEY, theme); }; const cycleTheme = (): void => { @@ -27,11 +74,11 @@ const initTheme = (): void => { // delegated click handler on theme buttons document.addEventListener('click', e => { - const target = e.target as HTMLElement; - const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null; - if (btn) { - cycleTheme(); - } + const target = e.target; + if (!(target instanceof Element)) return; + const btn = target.closest('#theme-toggle, #footer-theme-toggle'); + if (!(btn instanceof HTMLButtonElement)) return; + cycleTheme(); }); };