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
This commit is contained in:
Claude
2026-01-30 09:46:38 +00:00
committed by Eric Allam
parent b221719c09
commit 9caff5ade2
9 changed files with 394 additions and 42 deletions
@@ -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 }) {
<LayoutGroup>
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
<ShortcutsAutoOpen />
<HelpAndFeedback isCollapsed={isCollapsed} />
<div className={cn("flex", isCollapsed ? "flex-col gap-1" : "items-center gap-0.5")}>
<ThemeToggle isCollapsed={isCollapsed} />
<HelpAndFeedback isCollapsed={isCollapsed} />
</div>
<AskAI isCollapsed={isCollapsed} />
</div>
</LayoutGroup>
@@ -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<ThemeContextValue | undefined>(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<ThemePreference>(initialPreference);
const [theme, setTheme] = useState<Theme>(() => 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 (
<ThemeContext.Provider value={{ theme, themePreference, setThemePreference }}>
{children}
</ThemeContext.Provider>
);
}
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 <head> 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 <script dangerouslySetInnerHTML={{ __html: script }} />;
}
@@ -0,0 +1,100 @@
import { ComputerDesktopIcon, MoonIcon, SunIcon } from "@heroicons/react/20/solid";
import { useTheme } from "./ThemeProvider";
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "./Popover";
import { Button } from "./Buttons";
import { SimpleTooltip } from "./Tooltip";
import { cn } from "~/utils/cn";
import { useState } from "react";
import type { ThemePreference } from "~/services/dashboardPreferences.server";
const themeOptions: { value: ThemePreference; label: string; icon: typeof SunIcon }[] = [
{ value: "light", label: "Light", icon: SunIcon },
{ value: "dark", label: "Dark", icon: MoonIcon },
{ value: "system", label: "System", icon: ComputerDesktopIcon },
];
interface ThemeToggleProps {
className?: string;
isCollapsed?: boolean;
}
export function ThemeToggle({ className, isCollapsed = false }: ThemeToggleProps) {
const { themePreference, setThemePreference } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const currentOption = themeOptions.find((opt) => opt.value === themePreference) ?? themeOptions[1];
const CurrentIcon = currentOption.icon;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<SimpleTooltip
button={
<PopoverTrigger asChild>
<Button
variant="minimal/small"
className={cn("aspect-square h-7 p-1", className)}
LeadingIcon={CurrentIcon}
/>
</PopoverTrigger>
}
content={`Theme: ${currentOption.label}`}
side={isCollapsed ? "right" : "top"}
hidden={isOpen}
disableHoverableContent
/>
<PopoverContent
className="min-w-[10rem] p-1"
side={isCollapsed ? "right" : "top"}
align="start"
sideOffset={8}
>
{themeOptions.map((option) => (
<PopoverMenuItem
key={option.value}
icon={option.icon}
title={option.label}
isSelected={themePreference === option.value}
onClick={() => {
setThemePreference(option.value);
setIsOpen(false);
}}
/>
))}
</PopoverContent>
</Popover>
);
}
interface ThemeToggleButtonsProps {
className?: string;
}
export function ThemeToggleButtons({ className }: ThemeToggleButtonsProps) {
const { themePreference, setThemePreference } = useTheme();
return (
<div className={cn("flex items-center gap-1 rounded-md bg-tertiary p-0.5", className)}>
{themeOptions.map((option) => {
const Icon = option.icon;
const isSelected = themePreference === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setThemePreference(option.value)}
className={cn(
"flex items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors",
isSelected
? "bg-background-bright text-text-bright shadow-sm"
: "text-text-dimmed hover:text-text-bright"
)}
title={option.label}
>
<Icon className="size-4" />
<span className="hidden sm:inline">{option.label}</span>
</button>
);
})}
</div>
);
}
+27 -16
View File
@@ -9,12 +9,14 @@ import tailwindStylesheetUrl from "~/tailwind.css";
import { RouteErrorDisplay } from "./components/ErrorDisplay";
import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout";
import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider";
import { ThemeProvider, ThemeScript } from "./components/primitives/ThemeProvider";
import { Toast } from "./components/primitives/Toast";
import { env } from "./env.server";
import { featuresForRequest } from "./features.server";
import { usePostHog } from "./hooks/usePostHog";
import { getUser } from "./services/session.server";
import { appEnvTitleTag } from "./utils";
import type { ThemePreference } from "./services/dashboardPreferences.server";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
@@ -55,9 +57,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
websiteId: env.KAPA_AI_WEBSITE_ID,
};
const user = await getUser(request);
const themePreference: ThemePreference = user?.dashboardPreferences?.theme ?? "dark";
return typedjson(
{
user: await getUser(request),
user,
toastMessage,
posthogProjectKey,
features,
@@ -65,6 +70,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
appOrigin: env.APP_ORIGIN,
triggerCliTag: env.TRIGGER_CLI_TAG,
kapa,
themePreference,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
);
@@ -83,21 +89,23 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
export function ErrorBoundary() {
return (
<>
<html lang="en" className="h-full">
<html lang="en" className="h-full dark">
<head>
<meta charSet="utf-8" />
<ThemeScript />
<Meta />
<Links />
</head>
<body className="h-full overflow-hidden bg-background-dimmed">
<ShortcutsProvider>
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
</AppContainer>
</ShortcutsProvider>
<ThemeProvider initialPreference="dark" isLoggedIn={false}>
<ShortcutsProvider>
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
</AppContainer>
</ShortcutsProvider>
</ThemeProvider>
<Scripts />
</body>
</html>
@@ -106,21 +114,24 @@ export function ErrorBoundary() {
}
export default function App() {
const { posthogProjectKey, kapa } = useTypedLoaderData<typeof loader>();
const { posthogProjectKey, kapa, themePreference, user } = useTypedLoaderData<typeof loader>();
usePostHog(posthogProjectKey);
return (
<>
<html lang="en" className="h-full">
<html lang="en" className="h-full dark">
<head>
<ThemeScript initialPreference={themePreference} />
<Meta />
<Links />
</head>
<body className="h-full overflow-hidden bg-background-dimmed">
<ShortcutsProvider>
<Outlet />
<Toast />
</ShortcutsProvider>
<ThemeProvider initialPreference={themePreference} isLoggedIn={!!user}>
<ShortcutsProvider>
<Outlet />
<Toast />
</ShortcutsProvider>
</ThemeProvider>
<ScrollRestoration />
<ExternalScripts />
<Scripts />
@@ -22,6 +22,7 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { ThemeToggleButtons } from "~/components/primitives/ThemeToggle";
import { prisma } from "~/db.server";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -196,6 +197,14 @@ export default function Page() {
/>
</Fieldset>
</Form>
<div className="mt-6 w-full border-t border-grid-dimmed pt-6">
<Header2 className="mb-3">Appearance</Header2>
<InputGroup>
<Label>Theme</Label>
<ThemeToggleButtons />
<Hint>Choose your preferred color scheme</Hint>
</InputGroup>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
@@ -0,0 +1,27 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { ThemePreference, updateThemePreference } from "~/services/dashboardPreferences.server";
import { requireUser } from "~/services/session.server";
const RequestSchema = z.object({
theme: ThemePreference,
});
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const rawData = Object.fromEntries(formData);
const result = RequestSchema.safeParse(rawData);
if (!result.success) {
return json({ success: false, error: "Invalid request data" }, { status: 400 });
}
await updateThemePreference({
user,
theme: result.data.theme,
});
return json({ success: true });
}
@@ -10,6 +10,9 @@ const SideMenuPreferences = z.object({
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
export const ThemePreference = z.enum(["light", "dark", "system"]).default("dark");
export type ThemePreference = z.infer<typeof ThemePreference>;
const DashboardPreferences = z.object({
version: z.literal("1"),
currentProjectId: z.string().optional(),
@@ -20,6 +23,7 @@ const DashboardPreferences = z.object({
})
),
sideMenu: SideMenuPreferences.optional(),
theme: ThemePreference.optional(),
});
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
@@ -151,3 +155,34 @@ export async function updateSideMenuPreferences({
},
});
}
export async function updateThemePreference({
user,
theme,
}: {
user: UserFromSession;
theme: ThemePreference;
}) {
if (user.isImpersonating) {
return;
}
// Only update if something changed
if (user.dashboardPreferences.theme === theme) {
return;
}
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
theme,
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
});
}
+48 -16
View File
@@ -33,17 +33,41 @@
::-moz-selection {
@apply bg-text-bright/30 text-text-bright;
}
/* shadcn charts: https://ui.shadcn.com/docs/components/chart#add-a-grid */
/* Theme color variables */
/* Light mode (default) */
:root {
/* Theme-aware semantic colors */
--color-secondary: #D7D9DD; /* charcoal[200] */
--color-tertiary: #E8E9EC; /* charcoal[100] */
--color-text-dimmed: #5F6570; /* charcoal[500] */
--color-text-bright: #272A2E; /* charcoal[700] */
--color-background-bright: #E8E9EC; /* charcoal[100] */
--color-background-dimmed: #F5F5F7; /* lighter than charcoal[100] */
--color-grid-bright: #B5B8C0; /* charcoal[300] */
--color-grid-dimmed: #D7D9DD; /* charcoal[200] */
/* shadcn charts */
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
/* Dark mode */
.dark {
/* Theme-aware semantic colors */
--color-secondary: #2C3034; /* charcoal[650] */
--color-tertiary: #272A2E; /* charcoal[700] */
--color-text-dimmed: #878C99; /* charcoal[400] */
--color-text-bright: #D7D9DD; /* charcoal[200] */
--color-background-bright: #1A1B1F; /* charcoal[800] */
--color-background-dimmed: #15171A; /* charcoal[850] */
--color-grid-bright: #272A2E; /* charcoal[700] */
--color-grid-dimmed: #212327; /* charcoal[750] */
/* shadcn charts */
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
@@ -116,10 +140,10 @@
/* Streamdown markdown styling */
.streamdown-container {
/* Streamdown uses shadcn/ui CSS variables - define them for our theme */
--muted: 220 13% 20%;
--muted-foreground: 215 14% 60%;
--foreground: 210 20% 90%;
--border: 217 19% 27%;
--muted: 220 13% 91%;
--muted-foreground: 215 14% 40%;
--foreground: 210 20% 15%;
--border: 217 19% 80%;
& p {
@apply my-1;
@@ -150,13 +174,13 @@
}
/* Inline code (not in pre blocks) */
& code:not(pre code) {
@apply bg-charcoal-700 px-1 py-0.5 rounded text-text-bright font-mono;
@apply bg-secondary px-1 py-0.5 rounded text-text-bright font-mono;
}
& blockquote {
@apply border-l-2 border-charcoal-600 pl-3 my-2 italic;
@apply border-l-2 border-grid-bright pl-3 my-2 italic;
}
& a {
@apply text-blue-400 hover:underline;
@apply text-blue-500 dark:text-blue-400 hover:underline;
}
& strong {
@apply font-semibold text-text-bright;
@@ -165,24 +189,24 @@
@apply italic;
}
& hr {
@apply my-2 border-charcoal-600;
@apply my-2 border-grid-bright;
}
& table {
@apply w-full my-2 border-collapse;
}
& th, & td {
@apply border border-charcoal-600 px-2 py-1 text-left;
@apply border border-grid-bright px-2 py-1 text-left;
}
& th {
@apply bg-charcoal-700 font-semibold;
@apply bg-secondary font-semibold;
}
/* Streamdown code block container */
& [data-code-block-container] {
@apply my-2 border-charcoal-700;
@apply my-2 border-grid-bright;
}
& [data-code-block-header] {
@apply bg-charcoal-800 text-text-dimmed border-b border-charcoal-700;
@apply bg-background-bright text-text-dimmed border-b border-grid-bright;
}
/* Hide light mode code block, show dark mode */
& [data-code-block].dark\:hidden {
@@ -194,7 +218,7 @@
/* Override the bg-muted/40 class to let inline styles work */
& [data-code-block] pre {
background-color: inherit !important;
@apply scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600;
@apply scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-400 dark:scrollbar-thumb-charcoal-600;
}
& [data-code-block] pre code {
@apply bg-transparent;
@@ -203,3 +227,11 @@
@apply leading-relaxed;
}
}
/* Dark mode streamdown overrides */
.dark .streamdown-container {
--muted: 220 13% 20%;
--muted-foreground: 215 14% 60%;
--foreground: 210 20% 90%;
--border: 217 19% 27%;
}
+21 -9
View File
@@ -133,7 +133,7 @@ const lavender = {
/** Trigger.dev custom palette */
/** Text colors */
/** Text colors - Dark mode values (used as CSS variable fallbacks) */
const primary = apple[500];
const secondary = charcoal[650];
const tertiary = charcoal[700];
@@ -153,6 +153,16 @@ const stagingEnv = colors.orange[400];
const previewEnv = colors.yellow[400];
const prodEnv = mint[500];
/** Light mode color values */
const lightTextDimmed = charcoal[500];
const lightTextBright = charcoal[800];
const lightBackgroundBright = charcoal[100];
const lightBackgroundDimmed = charcoal[200];
const lightGridBright = charcoal[300];
const lightGridDimmed = charcoal[200];
const lightSecondary = charcoal[200];
const lightTertiary = charcoal[100];
/** Icon colors */
const tasks = colors.blue[500];
const runs = colors.indigo[500];
@@ -178,6 +188,7 @@ const radius = "0.5rem";
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./app/**/*.{ts,jsx,tsx}"],
theme: {
container: {
@@ -217,15 +228,16 @@ module.exports = {
mint,
sun,
primary,
secondary,
tertiary,
// Theme-aware colors using CSS variables with fallbacks
secondary: "var(--color-secondary)",
tertiary: "var(--color-tertiary)",
"text-link": textLink,
"text-dimmed": textDimmed,
"text-bright": textBright,
"background-bright": backgroundBright,
"background-dimmed": backgroundDimmed,
"grid-bright": gridBright,
"grid-dimmed": gridDimmed,
"text-dimmed": "var(--color-text-dimmed)",
"text-bright": "var(--color-text-bright)",
"background-bright": "var(--color-background-bright)",
"background-dimmed": "var(--color-background-dimmed)",
"grid-bright": "var(--color-grid-bright)",
"grid-dimmed": "var(--color-grid-dimmed)",
success,
pending,
warning,