Skip to content

Commit 9caff5a

Browse files
claudeericallam
authored andcommitted
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
1 parent b221719 commit 9caff5a

File tree

9 files changed

+394
-42
lines changed

9 files changed

+394
-42
lines changed

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import { TextLink } from "../primitives/TextLink";
9898
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
9999
import { ShortcutsAutoOpen } from "../Shortcuts";
100100
import { UserProfilePhoto } from "../UserProfilePhoto";
101+
import { ThemeToggle } from "../primitives/ThemeToggle";
101102
import { EnvironmentSelector } from "./EnvironmentSelector";
102103
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
103104
import { SideMenuHeader } from "./SideMenuHeader";
@@ -892,7 +893,10 @@ function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
892893
<LayoutGroup>
893894
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
894895
<ShortcutsAutoOpen />
895-
<HelpAndFeedback isCollapsed={isCollapsed} />
896+
<div className={cn("flex", isCollapsed ? "flex-col gap-1" : "items-center gap-0.5")}>
897+
<ThemeToggle isCollapsed={isCollapsed} />
898+
<HelpAndFeedback isCollapsed={isCollapsed} />
899+
</div>
896900
<AskAI isCollapsed={isCollapsed} />
897901
</div>
898902
</LayoutGroup>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useFetcher } from "@remix-run/react";
2+
import {
3+
createContext,
4+
useCallback,
5+
useContext,
6+
useEffect,
7+
useState,
8+
type ReactNode,
9+
} from "react";
10+
import type { ThemePreference } from "~/services/dashboardPreferences.server";
11+
12+
type Theme = "light" | "dark";
13+
14+
interface ThemeContextValue {
15+
theme: Theme;
16+
themePreference: ThemePreference;
17+
setThemePreference: (preference: ThemePreference) => void;
18+
}
19+
20+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
21+
22+
function getSystemTheme(): Theme {
23+
if (typeof window === "undefined") {
24+
return "dark";
25+
}
26+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
27+
}
28+
29+
function resolveTheme(preference: ThemePreference): Theme {
30+
if (preference === "system") {
31+
return getSystemTheme();
32+
}
33+
return preference;
34+
}
35+
36+
interface ThemeProviderProps {
37+
children: ReactNode;
38+
initialPreference?: ThemePreference;
39+
isLoggedIn?: boolean;
40+
}
41+
42+
export function ThemeProvider({
43+
children,
44+
initialPreference = "dark",
45+
isLoggedIn = false,
46+
}: ThemeProviderProps) {
47+
const [themePreference, setThemePreferenceState] = useState<ThemePreference>(initialPreference);
48+
const [theme, setTheme] = useState<Theme>(() => resolveTheme(initialPreference));
49+
const fetcher = useFetcher();
50+
51+
// Update the HTML class when theme changes
52+
useEffect(() => {
53+
const root = document.documentElement;
54+
root.classList.remove("light", "dark");
55+
root.classList.add(theme);
56+
}, [theme]);
57+
58+
// Listen for system theme changes when preference is "system"
59+
useEffect(() => {
60+
if (themePreference !== "system") {
61+
return;
62+
}
63+
64+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
65+
const handleChange = (e: MediaQueryListEvent) => {
66+
setTheme(e.matches ? "dark" : "light");
67+
};
68+
69+
mediaQuery.addEventListener("change", handleChange);
70+
return () => mediaQuery.removeEventListener("change", handleChange);
71+
}, [themePreference]);
72+
73+
const setThemePreference = useCallback(
74+
(preference: ThemePreference) => {
75+
setThemePreferenceState(preference);
76+
setTheme(resolveTheme(preference));
77+
78+
// Persist to server if logged in
79+
if (isLoggedIn) {
80+
fetcher.submit(
81+
{ theme: preference },
82+
{ method: "POST", action: "/resources/preferences/theme" }
83+
);
84+
}
85+
86+
// Also store in localStorage for non-logged-in users and faster hydration
87+
localStorage.setItem("theme-preference", preference);
88+
},
89+
[isLoggedIn, fetcher]
90+
);
91+
92+
return (
93+
<ThemeContext.Provider value={{ theme, themePreference, setThemePreference }}>
94+
{children}
95+
</ThemeContext.Provider>
96+
);
97+
}
98+
99+
export function useTheme() {
100+
const context = useContext(ThemeContext);
101+
if (context === undefined) {
102+
throw new Error("useTheme must be used within a ThemeProvider");
103+
}
104+
return context;
105+
}
106+
107+
// Script to prevent flash of wrong theme on initial load
108+
// This should be injected into the <head> before any content renders
109+
export function ThemeScript({ initialPreference }: { initialPreference?: ThemePreference }) {
110+
const script = `
111+
(function() {
112+
var preference = ${JSON.stringify(initialPreference ?? null)} || localStorage.getItem('theme-preference') || 'dark';
113+
var theme = preference;
114+
if (preference === 'system') {
115+
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
116+
}
117+
document.documentElement.classList.add(theme);
118+
})();
119+
`;
120+
121+
return <script dangerouslySetInnerHTML={{ __html: script }} />;
122+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { ComputerDesktopIcon, MoonIcon, SunIcon } from "@heroicons/react/20/solid";
2+
import { useTheme } from "./ThemeProvider";
3+
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "./Popover";
4+
import { Button } from "./Buttons";
5+
import { SimpleTooltip } from "./Tooltip";
6+
import { cn } from "~/utils/cn";
7+
import { useState } from "react";
8+
import type { ThemePreference } from "~/services/dashboardPreferences.server";
9+
10+
const themeOptions: { value: ThemePreference; label: string; icon: typeof SunIcon }[] = [
11+
{ value: "light", label: "Light", icon: SunIcon },
12+
{ value: "dark", label: "Dark", icon: MoonIcon },
13+
{ value: "system", label: "System", icon: ComputerDesktopIcon },
14+
];
15+
16+
interface ThemeToggleProps {
17+
className?: string;
18+
isCollapsed?: boolean;
19+
}
20+
21+
export function ThemeToggle({ className, isCollapsed = false }: ThemeToggleProps) {
22+
const { themePreference, setThemePreference } = useTheme();
23+
const [isOpen, setIsOpen] = useState(false);
24+
25+
const currentOption = themeOptions.find((opt) => opt.value === themePreference) ?? themeOptions[1];
26+
const CurrentIcon = currentOption.icon;
27+
28+
return (
29+
<Popover open={isOpen} onOpenChange={setIsOpen}>
30+
<SimpleTooltip
31+
button={
32+
<PopoverTrigger asChild>
33+
<Button
34+
variant="minimal/small"
35+
className={cn("aspect-square h-7 p-1", className)}
36+
LeadingIcon={CurrentIcon}
37+
/>
38+
</PopoverTrigger>
39+
}
40+
content={`Theme: ${currentOption.label}`}
41+
side={isCollapsed ? "right" : "top"}
42+
hidden={isOpen}
43+
disableHoverableContent
44+
/>
45+
<PopoverContent
46+
className="min-w-[10rem] p-1"
47+
side={isCollapsed ? "right" : "top"}
48+
align="start"
49+
sideOffset={8}
50+
>
51+
{themeOptions.map((option) => (
52+
<PopoverMenuItem
53+
key={option.value}
54+
icon={option.icon}
55+
title={option.label}
56+
isSelected={themePreference === option.value}
57+
onClick={() => {
58+
setThemePreference(option.value);
59+
setIsOpen(false);
60+
}}
61+
/>
62+
))}
63+
</PopoverContent>
64+
</Popover>
65+
);
66+
}
67+
68+
interface ThemeToggleButtonsProps {
69+
className?: string;
70+
}
71+
72+
export function ThemeToggleButtons({ className }: ThemeToggleButtonsProps) {
73+
const { themePreference, setThemePreference } = useTheme();
74+
75+
return (
76+
<div className={cn("flex items-center gap-1 rounded-md bg-tertiary p-0.5", className)}>
77+
{themeOptions.map((option) => {
78+
const Icon = option.icon;
79+
const isSelected = themePreference === option.value;
80+
return (
81+
<button
82+
key={option.value}
83+
type="button"
84+
onClick={() => setThemePreference(option.value)}
85+
className={cn(
86+
"flex items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors",
87+
isSelected
88+
? "bg-background-bright text-text-bright shadow-sm"
89+
: "text-text-dimmed hover:text-text-bright"
90+
)}
91+
title={option.label}
92+
>
93+
<Icon className="size-4" />
94+
<span className="hidden sm:inline">{option.label}</span>
95+
</button>
96+
);
97+
})}
98+
</div>
99+
);
100+
}

apps/webapp/app/root.tsx

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import tailwindStylesheetUrl from "~/tailwind.css";
99
import { RouteErrorDisplay } from "./components/ErrorDisplay";
1010
import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout";
1111
import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider";
12+
import { ThemeProvider, ThemeScript } from "./components/primitives/ThemeProvider";
1213
import { Toast } from "./components/primitives/Toast";
1314
import { env } from "./env.server";
1415
import { featuresForRequest } from "./features.server";
1516
import { usePostHog } from "./hooks/usePostHog";
1617
import { getUser } from "./services/session.server";
1718
import { appEnvTitleTag } from "./utils";
19+
import type { ThemePreference } from "./services/dashboardPreferences.server";
1820

1921
export const links: LinksFunction = () => {
2022
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
@@ -55,16 +57,20 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
5557
websiteId: env.KAPA_AI_WEBSITE_ID,
5658
};
5759

60+
const user = await getUser(request);
61+
const themePreference: ThemePreference = user?.dashboardPreferences?.theme ?? "dark";
62+
5863
return typedjson(
5964
{
60-
user: await getUser(request),
65+
user,
6166
toastMessage,
6267
posthogProjectKey,
6368
features,
6469
appEnv: env.APP_ENV,
6570
appOrigin: env.APP_ORIGIN,
6671
triggerCliTag: env.TRIGGER_CLI_TAG,
6772
kapa,
73+
themePreference,
6874
},
6975
{ headers: { "Set-Cookie": await commitSession(session) } }
7076
);
@@ -83,21 +89,23 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
8389
export function ErrorBoundary() {
8490
return (
8591
<>
86-
<html lang="en" className="h-full">
92+
<html lang="en" className="h-full dark">
8793
<head>
8894
<meta charSet="utf-8" />
89-
95+
<ThemeScript />
9096
<Meta />
9197
<Links />
9298
</head>
9399
<body className="h-full overflow-hidden bg-background-dimmed">
94-
<ShortcutsProvider>
95-
<AppContainer>
96-
<MainCenteredContainer>
97-
<RouteErrorDisplay />
98-
</MainCenteredContainer>
99-
</AppContainer>
100-
</ShortcutsProvider>
100+
<ThemeProvider initialPreference="dark" isLoggedIn={false}>
101+
<ShortcutsProvider>
102+
<AppContainer>
103+
<MainCenteredContainer>
104+
<RouteErrorDisplay />
105+
</MainCenteredContainer>
106+
</AppContainer>
107+
</ShortcutsProvider>
108+
</ThemeProvider>
101109
<Scripts />
102110
</body>
103111
</html>
@@ -106,21 +114,24 @@ export function ErrorBoundary() {
106114
}
107115

108116
export default function App() {
109-
const { posthogProjectKey, kapa } = useTypedLoaderData<typeof loader>();
117+
const { posthogProjectKey, kapa, themePreference, user } = useTypedLoaderData<typeof loader>();
110118
usePostHog(posthogProjectKey);
111119

112120
return (
113121
<>
114-
<html lang="en" className="h-full">
122+
<html lang="en" className="h-full dark">
115123
<head>
124+
<ThemeScript initialPreference={themePreference} />
116125
<Meta />
117126
<Links />
118127
</head>
119128
<body className="h-full overflow-hidden bg-background-dimmed">
120-
<ShortcutsProvider>
121-
<Outlet />
122-
<Toast />
123-
</ShortcutsProvider>
129+
<ThemeProvider initialPreference={themePreference} isLoggedIn={!!user}>
130+
<ShortcutsProvider>
131+
<Outlet />
132+
<Toast />
133+
</ShortcutsProvider>
134+
</ThemeProvider>
124135
<ScrollRestoration />
125136
<ExternalScripts />
126137
<Scripts />

apps/webapp/app/routes/account._index/route.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Input } from "~/components/primitives/Input";
2222
import { InputGroup } from "~/components/primitives/InputGroup";
2323
import { Label } from "~/components/primitives/Label";
2424
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
25+
import { ThemeToggleButtons } from "~/components/primitives/ThemeToggle";
2526
import { prisma } from "~/db.server";
2627
import { useUser } from "~/hooks/useUser";
2728
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -196,6 +197,14 @@ export default function Page() {
196197
/>
197198
</Fieldset>
198199
</Form>
200+
<div className="mt-6 w-full border-t border-grid-dimmed pt-6">
201+
<Header2 className="mb-3">Appearance</Header2>
202+
<InputGroup>
203+
<Label>Theme</Label>
204+
<ThemeToggleButtons />
205+
<Hint>Choose your preferred color scheme</Hint>
206+
</InputGroup>
207+
</div>
199208
</MainHorizontallyCenteredContainer>
200209
</PageBody>
201210
</PageContainer>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { json, type ActionFunctionArgs } from "@remix-run/node";
2+
import { z } from "zod";
3+
import { ThemePreference, updateThemePreference } from "~/services/dashboardPreferences.server";
4+
import { requireUser } from "~/services/session.server";
5+
6+
const RequestSchema = z.object({
7+
theme: ThemePreference,
8+
});
9+
10+
export async function action({ request }: ActionFunctionArgs) {
11+
const user = await requireUser(request);
12+
13+
const formData = await request.formData();
14+
const rawData = Object.fromEntries(formData);
15+
16+
const result = RequestSchema.safeParse(rawData);
17+
if (!result.success) {
18+
return json({ success: false, error: "Invalid request data" }, { status: 400 });
19+
}
20+
21+
await updateThemePreference({
22+
user,
23+
theme: result.data.theme,
24+
});
25+
26+
return json({ success: true });
27+
}

0 commit comments

Comments
 (0)