Unverified Commit 832b200d authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): reactive system theme detection

parent f5d00f38
Loading
Loading
Loading
Loading
+1 −2
Changes for panel/index.html: 1 added line, 2 removed lines.
Original line number Diff line number Diff line
@@ -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 {
+1 −1
Changes for panel/src/App.tsx: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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>
+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";
@@ -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>
  );
}
+0 −6
Changes for panel/src/index.css: 0 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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;
  }
}
+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);
}