From 9caff5ade21414aaf416962a599df9bfb6622c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 09:46:38 +0000 Subject: [PATCH 1/2] feat(webapp): add light mode support to dashboard Add theme switching capability with light, dark, and system modes: - Enable Tailwind CSS dark mode with class strategy - Add CSS variables for theme-aware semantic colors - Create ThemeProvider context for managing theme state - Add ThemeToggle component in side menu and account settings - Persist theme preference to user dashboard preferences - Include ThemeScript to prevent flash of wrong theme on load Slack thread: https://triggerdotdev.slack.com/archives/C04CR1HUWBV/p1769765128711039 https://claude.ai/code/session_01VDVgh75Xa4LA9YH9aFLnjB --- .../app/components/navigation/SideMenu.tsx | 6 +- .../components/primitives/ThemeProvider.tsx | 122 ++++++++++++++++++ .../app/components/primitives/ThemeToggle.tsx | 100 ++++++++++++++ apps/webapp/app/root.tsx | 43 +++--- .../app/routes/account._index/route.tsx | 9 ++ .../routes/resources.preferences.theme.tsx | 27 ++++ .../services/dashboardPreferences.server.ts | 35 +++++ apps/webapp/app/tailwind.css | 64 ++++++--- apps/webapp/tailwind.config.js | 30 +++-- 9 files changed, 394 insertions(+), 42 deletions(-) create mode 100644 apps/webapp/app/components/primitives/ThemeProvider.tsx create mode 100644 apps/webapp/app/components/primitives/ThemeToggle.tsx create mode 100644 apps/webapp/app/routes/resources.preferences.theme.tsx diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 1e7d48cd57..2147b73dfd 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -98,6 +98,7 @@ import { TextLink } from "../primitives/TextLink"; import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip"; import { ShortcutsAutoOpen } from "../Shortcuts"; import { UserProfilePhoto } from "../UserProfilePhoto"; +import { ThemeToggle } from "../primitives/ThemeToggle"; import { EnvironmentSelector } from "./EnvironmentSelector"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; @@ -892,7 +893,10 @@ function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
- +
+ + +
diff --git a/apps/webapp/app/components/primitives/ThemeProvider.tsx b/apps/webapp/app/components/primitives/ThemeProvider.tsx new file mode 100644 index 0000000000..d8a714d062 --- /dev/null +++ b/apps/webapp/app/components/primitives/ThemeProvider.tsx @@ -0,0 +1,122 @@ +import { useFetcher } from "@remix-run/react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import type { ThemePreference } from "~/services/dashboardPreferences.server"; + +type Theme = "light" | "dark"; + +interface ThemeContextValue { + theme: Theme; + themePreference: ThemePreference; + setThemePreference: (preference: ThemePreference) => void; +} + +const ThemeContext = createContext(undefined); + +function getSystemTheme(): Theme { + if (typeof window === "undefined") { + return "dark"; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function resolveTheme(preference: ThemePreference): Theme { + if (preference === "system") { + return getSystemTheme(); + } + return preference; +} + +interface ThemeProviderProps { + children: ReactNode; + initialPreference?: ThemePreference; + isLoggedIn?: boolean; +} + +export function ThemeProvider({ + children, + initialPreference = "dark", + isLoggedIn = false, +}: ThemeProviderProps) { + const [themePreference, setThemePreferenceState] = useState(initialPreference); + const [theme, setTheme] = useState(() => resolveTheme(initialPreference)); + const fetcher = useFetcher(); + + // Update the HTML class when theme changes + useEffect(() => { + const root = document.documentElement; + root.classList.remove("light", "dark"); + root.classList.add(theme); + }, [theme]); + + // Listen for system theme changes when preference is "system" + useEffect(() => { + if (themePreference !== "system") { + return; + } + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (e: MediaQueryListEvent) => { + setTheme(e.matches ? "dark" : "light"); + }; + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [themePreference]); + + const setThemePreference = useCallback( + (preference: ThemePreference) => { + setThemePreferenceState(preference); + setTheme(resolveTheme(preference)); + + // Persist to server if logged in + if (isLoggedIn) { + fetcher.submit( + { theme: preference }, + { method: "POST", action: "/resources/preferences/theme" } + ); + } + + // Also store in localStorage for non-logged-in users and faster hydration + localStorage.setItem("theme-preference", preference); + }, + [isLoggedIn, fetcher] + ); + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} + +// Script to prevent flash of wrong theme on initial load +// This should be injected into the before any content renders +export function ThemeScript({ initialPreference }: { initialPreference?: ThemePreference }) { + const script = ` + (function() { + var preference = ${JSON.stringify(initialPreference ?? null)} || localStorage.getItem('theme-preference') || 'dark'; + var theme = preference; + if (preference === 'system') { + theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + document.documentElement.classList.add(theme); + })(); + `; + + return