From d244a537ea8c85df96a8cca14cd221700d360a72 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 1 Aug 2026 19:47:39 +0200 Subject: [PATCH] feat(toolrate): light/dark/system theme toggle with FOUC guard - use-theme hook: localStorage (toolrate-theme, default system), matchMedia change listener, shared singleton listener, sets .dark + color-scheme - ThemeToggle: lightbulb quick toggle (light/dark) + dropdown (light/dark/system) - Sidebar footer + mobile header + standalone login page - inline script in index.html to apply theme pre-render (no FOUC) - recharts axis ticks use hsl(var(--foreground)) for dark-mode readability --- artifacts/toolrate/index.html | 12 +++ artifacts/toolrate/src/components/layout.tsx | 33 ++++--- .../toolrate/src/components/theme-toggle.tsx | 67 ++++++++++++++ artifacts/toolrate/src/hooks/use-theme.ts | 91 +++++++++++++++++++ artifacts/toolrate/src/pages/analytics.tsx | 8 +- artifacts/toolrate/src/pages/login.tsx | 4 + artifacts/toolrate/src/pages/tool-detail.tsx | 2 +- 7 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 artifacts/toolrate/src/components/theme-toggle.tsx create mode 100644 artifacts/toolrate/src/hooks/use-theme.ts diff --git a/artifacts/toolrate/index.html b/artifacts/toolrate/index.html index 9970469..bccb9d9 100644 --- a/artifacts/toolrate/index.html +++ b/artifacts/toolrate/index.html @@ -16,6 +16,18 @@ +
diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index dfbdd83..80db35f 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -3,6 +3,7 @@ import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, Sh import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { ThemeToggle } from "@/components/theme-toggle"; export function Layout({ children }: { children: React.ReactNode }) { const [location] = useLocation(); @@ -43,7 +44,10 @@ export function Layout({ children }: { children: React.ReactNode }) { })} -
+
+
+ +
{isLoading ? (
@@ -97,18 +101,21 @@ export function Layout({ children }: { children: React.ReactNode }) { ToolRate
- {!isLoading && ( - isAuthenticated ? ( - - ) : ( - - ) - )} +
+ + {!isLoading && ( + isAuthenticated ? ( + + ) : ( + + ) + )} +
{children} diff --git a/artifacts/toolrate/src/components/theme-toggle.tsx b/artifacts/toolrate/src/components/theme-toggle.tsx new file mode 100644 index 0000000..2b14045 --- /dev/null +++ b/artifacts/toolrate/src/components/theme-toggle.tsx @@ -0,0 +1,67 @@ +import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from "@/components/ui/dropdown-menu"; +import { useTheme, type Theme } from "@/hooks/use-theme"; + +export function ThemeToggle() { + const { theme, setTheme, resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + function quickToggle() { + setTheme(isDark ? "light" : "dark"); + } + + return ( +
+ + + + + + + Theme + setTheme(v as Theme)}> + + + Light + + + + Dark + + + + System + + + + +
+ ); +} diff --git a/artifacts/toolrate/src/hooks/use-theme.ts b/artifacts/toolrate/src/hooks/use-theme.ts new file mode 100644 index 0000000..e652624 --- /dev/null +++ b/artifacts/toolrate/src/hooks/use-theme.ts @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "toolrate-theme"; +const THEME_VALUES = ["light", "dark", "system"] as const; + +export type Theme = (typeof THEME_VALUES)[number]; +type ResolvedTheme = "light" | "dark"; + +function darkQuery(): MediaQueryList { + return window.matchMedia("(prefers-color-scheme: dark)"); +} + +export function resolveTheme(theme: Theme): ResolvedTheme { + if (theme === "system") { + return typeof window !== "undefined" && darkQuery().matches ? "dark" : "light"; + } + return theme; +} + +function applyTheme(resolved: ResolvedTheme) { + const root = document.documentElement; + root.classList.toggle("dark", resolved === "dark"); + root.style.colorScheme = resolved; +} + +function getStoredTheme(): Theme { + if (typeof window === "undefined") return "system"; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored && (THEME_VALUES as readonly string[]).includes(stored) + ? (stored as Theme) + : "system"; + } catch { + return "system"; + } +} + +function storeTheme(theme: Theme) { + try { + window.localStorage.setItem(STORAGE_KEY, theme); + } catch { + /* ignore */ + } +} + +const listeners = new Set<(theme: Theme) => void>(); + +function broadcast(theme: Theme) { + for (const listener of listeners) { + listener(theme); + } +} + +export function useTheme() { + const [theme, setThemeState] = useState(getStoredTheme); + + // Keep every mounted hook instance in sync (sidebar, mobile header, login). + useEffect(() => { + const listener = (next: Theme) => setThemeState(next); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, []); + + useEffect(() => { + applyTheme(resolveTheme(theme)); + if (theme !== "system") { + storeTheme(theme); + broadcast(theme); + return; + } + const onChange = () => applyTheme(resolveTheme("system")); + darkQuery().addEventListener("change", onChange); + return () => darkQuery().removeEventListener("change", onChange); + }, [theme]); + + function setTheme(next: Theme) { + if (!(THEME_VALUES as readonly string[]).includes(next)) return; + setThemeState(next); + storeTheme(next); + broadcast(next); + applyTheme(resolveTheme(next)); + } + + return { + theme, + setTheme, + resolvedTheme: resolveTheme(theme), + }; +} diff --git a/artifacts/toolrate/src/pages/analytics.tsx b/artifacts/toolrate/src/pages/analytics.tsx index 635e89d..25123c6 100644 --- a/artifacts/toolrate/src/pages/analytics.tsx +++ b/artifacts/toolrate/src/pages/analytics.tsx @@ -119,8 +119,8 @@ export default function Analytics() { - - `${v}`} /> + + `${v}`} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} /> - + @@ -189,7 +189,7 @@ export default function Analytics() { - + diff --git a/artifacts/toolrate/src/pages/login.tsx b/artifacts/toolrate/src/pages/login.tsx index a06e742..877f6f7 100644 --- a/artifacts/toolrate/src/pages/login.tsx +++ b/artifacts/toolrate/src/pages/login.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { ThemeToggle } from "@/components/theme-toggle"; import { Wrench, AlertCircle } from "lucide-react"; export default function Login() { @@ -40,6 +41,9 @@ export default function Login() { return (
+
+ +
diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index d7c4a42..6e68f78 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -697,7 +697,7 @@ export default function ToolDetail() { - `${val} ★`} /> + `${val} ★`} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />