(() => 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 ;
+}
diff --git a/apps/webapp/app/components/primitives/ThemeToggle.tsx b/apps/webapp/app/components/primitives/ThemeToggle.tsx
new file mode 100644
index 000000000..34a5e92a6
--- /dev/null
+++ b/apps/webapp/app/components/primitives/ThemeToggle.tsx
@@ -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 (
+
+
+
+
+ }
+ content={`Theme: ${currentOption.label}`}
+ side={isCollapsed ? "right" : "top"}
+ hidden={isOpen}
+ disableHoverableContent
+ />
+
+ {themeOptions.map((option) => (
+ {
+ setThemePreference(option.value);
+ setIsOpen(false);
+ }}
+ />
+ ))}
+
+
+ );
+}
+
+interface ThemeToggleButtonsProps {
+ className?: string;
+}
+
+export function ThemeToggleButtons({ className }: ThemeToggleButtonsProps) {
+ const { themePreference, setThemePreference } = useTheme();
+
+ return (
+
+ {themeOptions.map((option) => {
+ const Icon = option.icon;
+ const isSelected = themePreference === option.value;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx
index fb5fef9c8..bf80b9c1f 100644
--- a/apps/webapp/app/root.tsx
+++ b/apps/webapp/app/root.tsx
@@ -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 (
<>
-
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -106,21 +114,24 @@ export function ErrorBoundary() {
}
export default function App() {
- const { posthogProjectKey, kapa } = useTypedLoaderData();
+ const { posthogProjectKey, kapa, themePreference, user } = useTypedLoaderData();
usePostHog(posthogProjectKey);
return (
<>
-
+
+
-
-
-
-
+
+
+
+
+
+
diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx
index e32498a26..219e57790 100644
--- a/apps/webapp/app/routes/account._index/route.tsx
+++ b/apps/webapp/app/routes/account._index/route.tsx
@@ -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() {
/>
+
+ Appearance
+
+
+
+ Choose your preferred color scheme
+
+
diff --git a/apps/webapp/app/routes/resources.preferences.theme.tsx b/apps/webapp/app/routes/resources.preferences.theme.tsx
new file mode 100644
index 000000000..f1f7c8f07
--- /dev/null
+++ b/apps/webapp/app/routes/resources.preferences.theme.tsx
@@ -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 });
+}
diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts
index 323b96fa8..d174bb30e 100644
--- a/apps/webapp/app/services/dashboardPreferences.server.ts
+++ b/apps/webapp/app/services/dashboardPreferences.server.ts
@@ -10,6 +10,9 @@ const SideMenuPreferences = z.object({
export type SideMenuPreferences = z.infer;
+export const ThemePreference = z.enum(["light", "dark", "system"]).default("dark");
+export type ThemePreference = z.infer;
+
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;
@@ -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,
+ },
+ });
+}
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index 99e73c598..655d67ddf 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -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%;
+}
diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js
index d7ee33569..c6d807069 100644
--- a/apps/webapp/tailwind.config.js
+++ b/apps/webapp/tailwind.config.js
@@ -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,