Loading panel/index.html +1 −2 Changes for panel/index.html: 1 added line, 2 removed lines. Original line number Diff line number Diff line Loading @@ -6,8 +6,7 @@ <title>Felis · Control Panel</title> <script> try { const theme = localStorage.getItem("felis-theme") || "system"; const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); const dark = window.matchMedia("(prefers-color-scheme: dark)").matches; if (dark) { document.documentElement.classList.add("dark"); } else { Loading panel/src/App.tsx +1 −1 Changes for panel/src/App.tsx: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -22,7 +22,7 @@ import { FleetTable } from "@/pages/ops/FleetTable"; // the split is by concern, not by tier. TierProvider fetches /me once at boot. export default function App() { return ( <ThemeProvider storageKey="felis-theme"> <ThemeProvider> <TierProvider> <BrowserRouter> <Routes> Loading panel/src/components/AppShell.tsx +3 −2 Changes for panel/src/components/AppShell.tsx: 3 added lines, 2 removed lines. Original line number Diff line number Diff line import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { Cat, Globe, Sun, Moon, LogOut, UserRound } from "lucide-react"; import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; Loading Loading @@ -130,7 +130,8 @@ function ThemeToggle() { aria-label={t("toggle_theme")} title={t("toggle_theme")} > {theme === "light" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} {theme === "light" && <Sun className="h-4 w-4" />} {theme === "dark" && <Moon className="h-4 w-4" />} </button> ); } Loading panel/src/index.css +0 −6 Changes for panel/src/index.css: 0 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -55,10 +55,4 @@ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; } /* Global theme toggle transition */ .theme-transitioning, .theme-transitioning * { transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, text-decoration-color 150ms ease, fill 150ms ease, stroke 150ms ease !important; } } panel/src/lib/theme.tsx +74 −74 Changes for panel/src/lib/theme.tsx: 74 added lines, 74 removed lines. Original line number Diff line number Diff line import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react"; export type Theme = "light" | "dark"; type ThemeProviderProps = { children: React.ReactNode; storageKey?: string; }; type ThemeProviderState = { /** The resolved, active theme ("light" | "dark") applied to the DOM. */ theme: Theme; setTheme: (theme: Theme) => void; toggleTheme: () => void; }; const initialState: ThemeProviderState = { const ThemeContext = createContext<ThemeProviderState>({ theme: "dark", setTheme: () => null, toggleTheme: () => null, }; setTheme: () => {}, toggleTheme: () => {}, }); const ThemeProviderContext = createContext<ThemeProviderState>(initialState); export function ThemeProvider({ children, storageKey = "felis-theme", ...props }: ThemeProviderProps) { const [theme, setThemeState] = useState<Theme>(() => { const stored = localStorage.getItem(storageKey) as Theme | null; if (stored === "light" || stored === "dark") { return stored; const SYSTEM_DARK_QUERY = "(prefers-color-scheme: dark)"; // ---- system theme store ---- function systemThemeQuery() { if (typeof window === "undefined" || !window.matchMedia) return null; return window.matchMedia(SYSTEM_DARK_QUERY); } // Auto-detect system preference return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }); useEffect(() => { const root = window.document.documentElement; root.classList.remove("light", "dark"); root.classList.add(theme); }, [theme]); function systemThemeSnapshot(): Theme { return systemThemeQuery()?.matches ? "dark" : "light"; } // Listen to system preference changes ONLY IF the user hasn't explicitly set a preference yet useEffect(() => { const stored = localStorage.getItem(storageKey); if (stored === "light" || stored === "dark") { return; function applyClass(t: Theme) { const root = document.documentElement; if (t === "dark") { root.classList.add("dark"); root.classList.remove("light"); } else { root.classList.add("light"); root.classList.remove("dark"); } } const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleSystemThemeChange = (e: MediaQueryListEvent) => { const root = window.document.documentElement; root.classList.add("theme-transitioning"); setThemeState(e.matches ? "dark" : "light"); setTimeout(() => { root.classList.remove("theme-transitioning"); }, 150); }; // ---- provider ---- mediaQuery.addEventListener("change", handleSystemThemeChange); return () => { mediaQuery.removeEventListener("change", handleSystemThemeChange); }; }, [storageKey]); const setTheme = (newTheme: Theme) => { const root = window.document.documentElement; root.classList.add("theme-transitioning"); localStorage.setItem(storageKey, newTheme); setThemeState(newTheme); setTimeout(() => { root.classList.remove("theme-transitioning"); }, 150); }; export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setThemeState] = useState<Theme>(systemThemeSnapshot); const toggleTheme = () => { const setTheme = useCallback( (next: Theme) => { // Direct DOM write to prevent visual lag on transition. setThemeState(next); applyClass(next); }, [], ); const toggleTheme = useCallback(() => { setTheme(theme === "dark" ? "light" : "dark"); }; }, [theme, setTheme]); return ( <ThemeProviderContext.Provider {...props} value={{ theme, setTheme, toggleTheme }} > {children} </ThemeProviderContext.Provider> ); useEffect(() => { const mql = systemThemeQuery(); if (!mql) return; const syncSystemTheme = () => setThemeState(mql.matches ? "dark" : "light"); syncSystemTheme(); if (mql.addEventListener) { mql.addEventListener("change", syncSystemTheme); return () => mql.removeEventListener("change", syncSystemTheme); } export const useTheme = () => { const context = useContext(ThemeProviderContext); mql.addListener?.(syncSystemTheme); return () => mql.removeListener?.(syncSystemTheme); }, []); if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); // Sync the class whenever the resolved theme changes. useEffect(() => { applyClass(theme); }, [theme]); return context; }; const value = useMemo<ThemeProviderState>( () => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme], ); return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; } export function useTheme() { return useContext(ThemeContext); } Loading
panel/index.html +1 −2 Changes for panel/index.html: 1 added line, 2 removed lines. Original line number Diff line number Diff line Loading @@ -6,8 +6,7 @@ <title>Felis · Control Panel</title> <script> try { const theme = localStorage.getItem("felis-theme") || "system"; const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); const dark = window.matchMedia("(prefers-color-scheme: dark)").matches; if (dark) { document.documentElement.classList.add("dark"); } else { Loading
panel/src/App.tsx +1 −1 Changes for panel/src/App.tsx: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -22,7 +22,7 @@ import { FleetTable } from "@/pages/ops/FleetTable"; // the split is by concern, not by tier. TierProvider fetches /me once at boot. export default function App() { return ( <ThemeProvider storageKey="felis-theme"> <ThemeProvider> <TierProvider> <BrowserRouter> <Routes> Loading
panel/src/components/AppShell.tsx +3 −2 Changes for panel/src/components/AppShell.tsx: 3 added lines, 2 removed lines. Original line number Diff line number Diff line import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { Cat, Globe, Sun, Moon, LogOut, UserRound } from "lucide-react"; import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; Loading Loading @@ -130,7 +130,8 @@ function ThemeToggle() { aria-label={t("toggle_theme")} title={t("toggle_theme")} > {theme === "light" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} {theme === "light" && <Sun className="h-4 w-4" />} {theme === "dark" && <Moon className="h-4 w-4" />} </button> ); } Loading
panel/src/index.css +0 −6 Changes for panel/src/index.css: 0 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -55,10 +55,4 @@ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; } /* Global theme toggle transition */ .theme-transitioning, .theme-transitioning * { transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, text-decoration-color 150ms ease, fill 150ms ease, stroke 150ms ease !important; } }
panel/src/lib/theme.tsx +74 −74 Changes for panel/src/lib/theme.tsx: 74 added lines, 74 removed lines. Original line number Diff line number Diff line import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react"; export type Theme = "light" | "dark"; type ThemeProviderProps = { children: React.ReactNode; storageKey?: string; }; type ThemeProviderState = { /** The resolved, active theme ("light" | "dark") applied to the DOM. */ theme: Theme; setTheme: (theme: Theme) => void; toggleTheme: () => void; }; const initialState: ThemeProviderState = { const ThemeContext = createContext<ThemeProviderState>({ theme: "dark", setTheme: () => null, toggleTheme: () => null, }; setTheme: () => {}, toggleTheme: () => {}, }); const ThemeProviderContext = createContext<ThemeProviderState>(initialState); export function ThemeProvider({ children, storageKey = "felis-theme", ...props }: ThemeProviderProps) { const [theme, setThemeState] = useState<Theme>(() => { const stored = localStorage.getItem(storageKey) as Theme | null; if (stored === "light" || stored === "dark") { return stored; const SYSTEM_DARK_QUERY = "(prefers-color-scheme: dark)"; // ---- system theme store ---- function systemThemeQuery() { if (typeof window === "undefined" || !window.matchMedia) return null; return window.matchMedia(SYSTEM_DARK_QUERY); } // Auto-detect system preference return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }); useEffect(() => { const root = window.document.documentElement; root.classList.remove("light", "dark"); root.classList.add(theme); }, [theme]); function systemThemeSnapshot(): Theme { return systemThemeQuery()?.matches ? "dark" : "light"; } // Listen to system preference changes ONLY IF the user hasn't explicitly set a preference yet useEffect(() => { const stored = localStorage.getItem(storageKey); if (stored === "light" || stored === "dark") { return; function applyClass(t: Theme) { const root = document.documentElement; if (t === "dark") { root.classList.add("dark"); root.classList.remove("light"); } else { root.classList.add("light"); root.classList.remove("dark"); } } const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleSystemThemeChange = (e: MediaQueryListEvent) => { const root = window.document.documentElement; root.classList.add("theme-transitioning"); setThemeState(e.matches ? "dark" : "light"); setTimeout(() => { root.classList.remove("theme-transitioning"); }, 150); }; // ---- provider ---- mediaQuery.addEventListener("change", handleSystemThemeChange); return () => { mediaQuery.removeEventListener("change", handleSystemThemeChange); }; }, [storageKey]); const setTheme = (newTheme: Theme) => { const root = window.document.documentElement; root.classList.add("theme-transitioning"); localStorage.setItem(storageKey, newTheme); setThemeState(newTheme); setTimeout(() => { root.classList.remove("theme-transitioning"); }, 150); }; export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setThemeState] = useState<Theme>(systemThemeSnapshot); const toggleTheme = () => { const setTheme = useCallback( (next: Theme) => { // Direct DOM write to prevent visual lag on transition. setThemeState(next); applyClass(next); }, [], ); const toggleTheme = useCallback(() => { setTheme(theme === "dark" ? "light" : "dark"); }; }, [theme, setTheme]); return ( <ThemeProviderContext.Provider {...props} value={{ theme, setTheme, toggleTheme }} > {children} </ThemeProviderContext.Provider> ); useEffect(() => { const mql = systemThemeQuery(); if (!mql) return; const syncSystemTheme = () => setThemeState(mql.matches ? "dark" : "light"); syncSystemTheme(); if (mql.addEventListener) { mql.addEventListener("change", syncSystemTheme); return () => mql.removeEventListener("change", syncSystemTheme); } export const useTheme = () => { const context = useContext(ThemeProviderContext); mql.addListener?.(syncSystemTheme); return () => mql.removeListener?.(syncSystemTheme); }, []); if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); // Sync the class whenever the resolved theme changes. useEffect(() => { applyClass(theme); }, [theme]); return context; }; const value = useMemo<ThemeProviderState>( () => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme], ); return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; } export function useTheme() { return useContext(ThemeContext); }