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:
Generated
+10
-1
@@ -33,6 +33,7 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -9308,9 +9309,17 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss-animate": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || insiders"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+163
-39
@@ -1,64 +1,154 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
@source not "../../.01_projects/**";
|
@source not "../../.01_projects/**";
|
||||||
@source not "../../.claude/**";
|
@source not "../../.claude/**";
|
||||||
@source not "../../scripts/**";
|
@source not "../../scripts/**";
|
||||||
@source not "../../.planning/**";
|
@source not "../../.planning/**";
|
||||||
|
|
||||||
|
/* Class-based dark mode (Tailwind v4). The FOUC guard in layout.tsx toggles
|
||||||
|
`.dark` on <html> before paint; the useTheme hook keeps it in sync. */
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
Design tokens — iamcavalli brand palette (Tailwind v4 @theme)
|
Design tokens — iamcavalli brand palette
|
||||||
|
Raw values live in :root (light) / .dark (dark) so the whole
|
||||||
|
app themes off a single class toggle; @theme inline below maps
|
||||||
|
them to Tailwind color utilities (bg-background, text-primary…).
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
:root {
|
||||||
|
/* Base */
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #1a1a1a;
|
||||||
|
|
||||||
|
/* Brand primary — verde scuro iamcavalli */
|
||||||
|
--primary: #1A463C;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Brand accent — giallo lime */
|
||||||
|
--accent: #DEF168;
|
||||||
|
--accent-foreground: #1A463C;
|
||||||
|
|
||||||
|
/* Secondary — grigio neutro */
|
||||||
|
--secondary: #f4f4f5;
|
||||||
|
--secondary-foreground: #1a1a1a;
|
||||||
|
|
||||||
|
/* Muted — per testi e sfondi secondari */
|
||||||
|
--muted: #f9f9f9;
|
||||||
|
--muted-foreground: #71717a;
|
||||||
|
|
||||||
|
/* Destructive */
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #1a1a1a;
|
||||||
|
|
||||||
|
/* Popover */
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #1a1a1a;
|
||||||
|
|
||||||
|
/* Border / Input / Ring */
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--input: #e5e7eb;
|
||||||
|
--ring: #1A463C;
|
||||||
|
|
||||||
|
/* Semantic — stato task/pagamenti */
|
||||||
|
--success: #16a34a;
|
||||||
|
--warning: #ca8a04;
|
||||||
|
--info: #2563eb;
|
||||||
|
|
||||||
|
/* Legacy — usati inline nei componenti esistenti */
|
||||||
|
--tertiary: #999999;
|
||||||
|
--bg-subtle: #f9f9f9;
|
||||||
|
--border-light: #e5e5e5;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Base — verde-nero, coerente col brand */
|
||||||
|
--background: #0e1512;
|
||||||
|
--foreground: #f2f4f3;
|
||||||
|
|
||||||
|
/* Brand primary — verde schiarito per contrasto su fondo scuro */
|
||||||
|
--primary: #3FA88C;
|
||||||
|
--primary-foreground: #06110d;
|
||||||
|
|
||||||
|
/* Brand accent — lime invariato, testo verde scuro */
|
||||||
|
--accent: #DEF168;
|
||||||
|
--accent-foreground: #14231d;
|
||||||
|
|
||||||
|
--secondary: #1b2420;
|
||||||
|
--secondary-foreground: #f2f4f3;
|
||||||
|
|
||||||
|
--muted: #171f1b;
|
||||||
|
--muted-foreground: #9aa39e;
|
||||||
|
|
||||||
|
--destructive: #f87171;
|
||||||
|
--destructive-foreground: #1a0a0a;
|
||||||
|
|
||||||
|
--card: #131a16;
|
||||||
|
--card-foreground: #f2f4f3;
|
||||||
|
|
||||||
|
--popover: #131a16;
|
||||||
|
--popover-foreground: #f2f4f3;
|
||||||
|
|
||||||
|
--border: #26302b;
|
||||||
|
--input: #26302b;
|
||||||
|
--ring: #3FA88C;
|
||||||
|
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #eab308;
|
||||||
|
--info: #3b82f6;
|
||||||
|
|
||||||
|
--tertiary: #8a938d;
|
||||||
|
--bg-subtle: #171f1b;
|
||||||
|
--border-light: #26302b;
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
/* Font */
|
/* Font */
|
||||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
|
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
"Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
"Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
|
||||||
/* Base */
|
/* Colors — mapped from the raw tokens above */
|
||||||
--color-background: #ffffff;
|
--color-background: var(--background);
|
||||||
--color-foreground: #1a1a1a;
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
/* Brand primary — verde scuro iamcavalli */
|
--color-primary: var(--primary);
|
||||||
--color-primary: #1A463C;
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-primary-foreground: #ffffff;
|
|
||||||
|
|
||||||
/* Brand accent — giallo lime */
|
--color-accent: var(--accent);
|
||||||
--color-accent: #DEF168;
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-accent-foreground: #1A463C;
|
|
||||||
|
|
||||||
/* Secondary — grigio neutro */
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary: #f4f4f5;
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-secondary-foreground: #1a1a1a;
|
|
||||||
|
|
||||||
/* Muted — per testi e sfondi secondari */
|
--color-muted: var(--muted);
|
||||||
--color-muted: #f9f9f9;
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-muted-foreground: #71717a;
|
|
||||||
|
|
||||||
/* Destructive */
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive: #ef4444;
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-destructive-foreground: #ffffff;
|
|
||||||
|
|
||||||
/* Card */
|
--color-card: var(--card);
|
||||||
--color-card: #ffffff;
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card-foreground: #1a1a1a;
|
|
||||||
|
|
||||||
/* Popover */
|
--color-popover: var(--popover);
|
||||||
--color-popover: #ffffff;
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-popover-foreground: #1a1a1a;
|
|
||||||
|
|
||||||
/* Border / Input / Ring */
|
--color-border: var(--border);
|
||||||
--color-border: #e5e7eb;
|
--color-input: var(--input);
|
||||||
--color-input: #e5e7eb;
|
--color-ring: var(--ring);
|
||||||
--color-ring: #1A463C;
|
|
||||||
|
|
||||||
/* Semantic — stato task/pagamenti */
|
--color-success: var(--success);
|
||||||
--color-success: #16a34a;
|
--color-warning: var(--warning);
|
||||||
--color-warning: #ca8a04;
|
--color-info: var(--info);
|
||||||
--color-info: #2563eb;
|
|
||||||
|
|
||||||
/* Legacy — usati inline nei componenti esistenti */
|
--color-tertiary: var(--tertiary);
|
||||||
--color-tertiary: #999999;
|
--color-bg-subtle: var(--bg-subtle);
|
||||||
--color-bg-subtle: #f9f9f9;
|
--color-border-light: var(--border-light);
|
||||||
--color-border-light: #e5e5e5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
@@ -75,10 +165,44 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #ffffff;
|
background-color: var(--background);
|
||||||
color: #171717;
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Scrollbar utilities (ported from OMC design system)
|
||||||
|
========================================================= */
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide the native scrollbar without breaking scroll. */
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very thin, muted scrollbar — discoverable but low-noise. */
|
||||||
|
.thin-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
|
height: 3px;
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+6
-1
@@ -32,7 +32,12 @@ export default function RootLayout({
|
|||||||
lang="it"
|
lang="it"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col bg-white text-zinc-900">
|
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var k='iamcavalli:theme';var p=localStorage.getItem(k);var d=p==='dark'||((p==null||p==='system')&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',d);}catch(e){}})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Check, Monitor, Moon, Sun } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useTheme, type ThemePref } from '@/lib/theme'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const OPTIONS: { value: ThemePref; label: string; icon: typeof Sun }[] = [
|
||||||
|
{ value: 'system', label: 'Sistema', icon: Monitor },
|
||||||
|
{ value: 'light', label: 'Chiaro', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Scuro', icon: Moon },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Header theme switcher — Sistema / Chiaro / Scuro. Defaults to Sistema
|
||||||
|
* (follows the OS). Dropdown closes on outside-click + Esc. */
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { pref, resolved, setTheme } = useTheme()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
const onEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick)
|
||||||
|
document.addEventListener('keydown', onEsc)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick)
|
||||||
|
document.removeEventListener('keydown', onEsc)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const TriggerIcon = pref === 'system' ? Monitor : resolved === 'dark' ? Moon : Sun
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
title="Tema"
|
||||||
|
aria-label="Tema"
|
||||||
|
className="flex items-center justify-center size-9 rounded-full border bg-card/70 hover:bg-card transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<TriggerIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 w-40 bg-popover text-popover-foreground border rounded-xl shadow-xl overflow-hidden z-50 p-1">
|
||||||
|
{OPTIONS.map((o) => {
|
||||||
|
const Icon = o.icon
|
||||||
|
const active = o.value === pref
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTheme(o.value)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-sm transition-colors text-left',
|
||||||
|
active ? 'bg-primary/10 text-primary' : 'hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{o.label}
|
||||||
|
{active && <Check className="size-3.5 ml-auto" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
FileText,
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import ThemeToggle from "@/components/ThemeToggle";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||||
@@ -60,8 +61,12 @@ export function AdminSidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Theme + Logout */}
|
||||||
<div className="px-3 py-4 border-t border-white/10">
|
<div className="px-3 py-4 border-t border-white/10 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between px-3">
|
||||||
|
<span className="text-xs text-white/50">Tema</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||||
className="flex items-center gap-3 px-3 py-2 w-full rounded-md text-sm text-white/65 hover:text-white hover:bg-white/10 transition-colors"
|
className="flex items-center gap-3 px-3 py-2 w-full rounded-md text-sm text-white/65 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user