feat: dark/light theming system + design tokens (OMC design system port)

- Restructure design tokens into :root/.dark raw vars mapped via @theme inline
- Add light/dark/system theming: useTheme hook + FOUC guard + ThemeToggle
- Keep iamcavalli palette (primary #1A463C, accent #DEF168) and Geist fonts
- Derive brand-consistent dark palette (verde-nero bg, lightened primary)
- Add scrollbar utilities (.no-scrollbar/.thin-scrollbar)
- Install tailwindcss-animate, register via @plugin (Tailwind v4)
- Mount ThemeToggle in admin sidebar footer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 08:08:56 +01:00
parent add2176a6b
commit 43cb7e7469
7 changed files with 340 additions and 44 deletions
+72
View File
@@ -0,0 +1,72 @@
'use client'
import { useEffect, useState } from 'react'
/**
* App theme preference. 'system' follows the OS (prefers-color-scheme) and is
* the default; 'light' / 'dark' are explicit overrides. The resolved theme is
* applied by toggling the `dark` class on <html> (Tailwind darkMode via
* @custom-variant in globals.css).
*
* The same localStorage key is read by the inline FOUC guard in layout.tsx so
* the correct class is set before React mounts — keep THEME_STORAGE_KEY in sync there.
*/
export type ThemePref = 'system' | 'light' | 'dark'
export const THEME_STORAGE_KEY = 'iamcavalli:theme'
function systemPrefersDark(): boolean {
return (
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
)
}
export function readThemePref(): ThemePref {
if (typeof window === 'undefined') return 'system'
const v = window.localStorage.getItem(THEME_STORAGE_KEY)
return v === 'light' || v === 'dark' ? v : 'system'
}
export function resolveTheme(pref: ThemePref): 'light' | 'dark' {
return pref === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : pref
}
export function applyTheme(pref: ThemePref): void {
if (typeof document === 'undefined') return
document.documentElement.classList.toggle('dark', resolveTheme(pref) === 'dark')
}
/**
* Theme state hook. Initialises to 'system' on both server and first client
* render (avoids hydration mismatch — the FOUC guard already set the <html>
* class), then reconciles from localStorage after mount. Applies the resolved
* theme on change and, while in 'system' mode, re-applies live when the OS
* theme flips. Returns the raw preference, the resolved light/dark value, and a
* setter that persists (clearing the key for 'system' so the OS stays
* authoritative).
*/
export function useTheme() {
const [pref, setPref] = useState<ThemePref>('system')
useEffect(() => {
setPref(readThemePref())
}, [])
useEffect(() => {
applyTheme(pref)
if (pref !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => applyTheme('system')
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [pref])
function setTheme(next: ThemePref) {
if (next === 'system') window.localStorage.removeItem(THEME_STORAGE_KEY)
else window.localStorage.setItem(THEME_STORAGE_KEY, next)
setPref(next)
}
return { pref, resolved: resolveTheme(pref), setTheme }
}