From 8b1bc2ae98d646050bc884a743ec1f5eae78b20b Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Fri, 6 Feb 2026 20:17:36 -0800 Subject: [PATCH 1/3] Add dark mode with light/dark/system toggle Adds a theme toggle in the navbar that cycles through light, dark, and system modes. Persists preference to localStorage and respects OS-level dark mode when set to system. Replaces hardcoded colors across all components with MUI theme tokens for proper dark mode support. --- frontend/src/App.tsx | 24 +++- frontend/src/components/app-layout/Footer.tsx | 2 +- frontend/src/components/app-layout/NavBar.tsx | 48 +++++++- frontend/src/components/app-layout/styles.tsx | 7 +- .../src/components/codeblock/CodeBlock.tsx | 4 +- .../src/components/common/styles/theme.tsx | 105 ++++++++++-------- frontend/src/lib/store/themeStore.ts | 42 +++++++ .../pages/home/components/LeaderboardTile.tsx | 4 +- .../leaderboard/components/RankingLists.tsx | 6 +- frontend/src/pages/lectures/Lectures.tsx | 30 +++-- .../pages/working-groups/WorkingGroups.tsx | 31 ++++-- frontend/src/tests/test-utils.tsx | 5 +- 12 files changed, 220 insertions(+), 88 deletions(-) create mode 100644 frontend/src/lib/store/themeStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d0cbdfa..031e31ab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import "./App.css"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import AppLayout from "./components/app-layout/AppLayout"; import { CssBaseline, ThemeProvider } from "@mui/material"; -import { appTheme } from "./components/common/styles/theme"; +import { createAppTheme } from "./components/common/styles/theme"; import Leaderboard from "./pages/leaderboard/Leaderboard"; import Home from "./pages/home/Home"; import News from "./pages/news/News"; @@ -13,7 +13,8 @@ import Lectures from "./pages/lectures/Lectures"; import ErrorPage from "./pages/Error"; import Login from "./pages/login/login"; import { useAuthStore } from "./lib/store/authStore"; -import { useEffect } from "react"; +import { useThemeStore } from "./lib/store/themeStore"; +import { useEffect, useMemo } from "react"; const errorRoutes = [ { @@ -32,7 +33,7 @@ const errorRoutes = [ path: "*", code: 404, title: "Page Not Found", - description: "The page you’re looking for doesn’t exist.", + description: "The page you're looking for doesn't exist.", }, ]; @@ -43,8 +44,23 @@ function App() { fetchMe(); }, [fetchMe]); + const resolvedMode = useThemeStore((s) => s.resolvedMode); + const mode = useThemeStore((s) => s.mode); + const setMode = useThemeStore((s) => s.setMode); + + const theme = useMemo(() => createAppTheme(resolvedMode), [resolvedMode]); + + // Listen for OS-level dark mode changes when mode is "system" + useEffect(() => { + if (mode !== "system") return; + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = () => setMode("system"); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [mode, setMode]); + return ( - + diff --git a/frontend/src/components/app-layout/Footer.tsx b/frontend/src/components/app-layout/Footer.tsx index ba85fc6f..a9100169 100644 --- a/frontend/src/components/app-layout/Footer.tsx +++ b/frontend/src/components/app-layout/Footer.tsx @@ -11,7 +11,7 @@ export const FooterLinkContainer = styled(Container)(({ theme }) => ({ })); export const FooterBox = styled(Box)(({ theme }) => ({ - borderTop: "1px solid #ddd", + borderTop: `1px solid ${theme.palette.divider}`, paddingTop: theme.spacing(2), paddingBottom: theme.spacing(2), textAlign: "center", diff --git a/frontend/src/components/app-layout/NavBar.tsx b/frontend/src/components/app-layout/NavBar.tsx index df4c2ebc..f9b45e07 100644 --- a/frontend/src/components/app-layout/NavBar.tsx +++ b/frontend/src/components/app-layout/NavBar.tsx @@ -1,6 +1,10 @@ // components/NavBar.tsx -import { AppBar, Toolbar, Link, Box } from "@mui/material"; +import { AppBar, Toolbar, Link, Box, IconButton, Tooltip } from "@mui/material"; import ArrowOutwardIcon from "@mui/icons-material/ArrowOutward"; +import LightModeIcon from "@mui/icons-material/LightMode"; +import DarkModeIcon from "@mui/icons-material/DarkMode"; +import SettingsBrightnessIcon from "@mui/icons-material/SettingsBrightness"; +import { useTheme } from "@mui/material/styles"; import { flexRowCenter, flexRowCenterMediumGap, @@ -9,6 +13,7 @@ import { import { appBarStyle, brandStyle } from "./styles"; import { ConstrainedContainer } from "./ConstrainedContainer"; import NavUserProfile from "./NavUserProfile"; +import { useThemeStore } from "../../lib/store/themeStore"; export interface NavLink { label: string; @@ -17,6 +22,37 @@ export interface NavLink { } export default function NavBar() { + const mode = useThemeStore((s) => s.mode); + const setMode = useThemeStore((s) => s.setMode); + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + + const logoSrc = isDark + ? "/gpu-mode-logo/white.svg" + : "/gpu-mode-logo/black-cropped.svg"; + + const cycleMode = () => { + const next = + mode === "light" ? "dark" : mode === "dark" ? "system" : "light"; + setMode(next); + }; + + const modeIcon = + mode === "light" ? ( + + ) : mode === "dark" ? ( + + ) : ( + + ); + + const modeLabel = + mode === "light" + ? "Switch to dark mode" + : mode === "dark" + ? "Switch to system preference" + : "Switch to light mode"; + const links: NavLink[] = [ { label: "News", href: "/news" }, { label: "Lectures", href: "/lectures" }, @@ -34,7 +70,7 @@ export default function NavBar() { ))} - + + + + + {modeIcon} + + diff --git a/frontend/src/components/app-layout/styles.tsx b/frontend/src/components/app-layout/styles.tsx index d37dd62e..3d60f257 100644 --- a/frontend/src/components/app-layout/styles.tsx +++ b/frontend/src/components/app-layout/styles.tsx @@ -2,10 +2,11 @@ import type { SxProps, Theme } from "@mui/material"; import { flexRowCenter, mediumText } from "../common/styles/shared_style"; export const appBarStyle: SxProps = { - backgroundColor: "white", - color: "black", + backgroundColor: "background.paper", + color: "text.primary", boxShadow: "none", - borderBottom: "1px solid #ddd", + borderBottom: 1, + borderColor: "divider", width: "100%", maxWidth: "100vw", }; diff --git a/frontend/src/components/codeblock/CodeBlock.tsx b/frontend/src/components/codeblock/CodeBlock.tsx index 3dac3365..b905d317 100644 --- a/frontend/src/components/codeblock/CodeBlock.tsx +++ b/frontend/src/components/codeblock/CodeBlock.tsx @@ -3,6 +3,7 @@ import { Box, IconButton, Tooltip, useTheme } from "@mui/material"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; interface CodeBlockProps { code: string; @@ -29,6 +30,7 @@ const styles = { export default function CodeBlock({ code }: CodeBlockProps) { const [copied, setCopied] = useState(false); const theme = useTheme(); + const syntaxTheme = theme.palette.mode === "dark" ? oneDark : oneLight; const handleCopy = () => { navigator.clipboard.writeText(code).then(() => { @@ -71,7 +73,7 @@ export default function CodeBlock({ code }: CodeBlockProps) { > ({ styleOverrides: { root: { - border: `1px solid ${colorPalette.neutral}`, + border: `1px solid ${mode === "dark" ? "#333" : colorPalette.neutral}`, boxShadow: "none", borderRadius: "12px", padding: "16px", }, }, -}; +}); -const MuiButtonGlobalStyle: Components["MuiButton"] = { +const getMuiButtonGlobalStyle = ( + mode: PaletteMode, +): Components["MuiButton"] => ({ defaultProps: { variant: "contained", disableElevation: true, }, styleOverrides: { root: { - backgroundColor: "#e6f0ff", - color: "#333", + backgroundColor: mode === "dark" ? "#23272f" : "#e6f0ff", + color: mode === "dark" ? "#e0e0e0" : "#333", fontWeight: 500, fontSize: "0.9rem", textTransform: "none", borderRadius: "13px", padding: "1px 5px", boxShadow: "none", - border: "1px solid #ddd", + border: `1px solid ${mode === "dark" ? "#444" : "#ddd"}`, "&:hover": { - backgroundColor: "#d0e4ff", + backgroundColor: mode === "dark" ? "#2d3340" : "#d0e4ff", }, "&:active": { - backgroundColor: "#b3d4ff", + backgroundColor: mode === "dark" ? "#3a4050" : "#b3d4ff", }, }, }, -}; +}); -export const appTheme = createTheme({ - palette: { - primary: { - main: colorPalette.primary, - }, - secondary: { - main: colorPalette.secondary, - }, - custom: colorPalette, - }, - typography: { - fontFamily: - 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', - fontSize: 14, - h1: { - fontSize: "1.875rem", - lineHeight: "2.25rem", - fontWeight: 700, - marginTop: "1rem", +export function createAppTheme(mode: PaletteMode) { + return createTheme({ + palette: { + mode, + primary: { + main: colorPalette.primary, + }, + secondary: { + main: colorPalette.secondary, + }, + custom: colorPalette, + ...(mode === "dark" && { + background: { + default: "#0f1117", + paper: "#181a20", + }, + }), }, - h2: { - fontSize: "1.5rem", - lineHeight: "2rem", - fontWeight: 700, - marginTop: "1rem", + typography: { + fontFamily: + 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', + fontSize: 14, + h1: { + fontSize: "1.875rem", + lineHeight: "2.25rem", + fontWeight: 700, + marginTop: "1rem", + }, + h2: { + fontSize: "1.5rem", + lineHeight: "2rem", + fontWeight: 700, + marginTop: "1rem", + }, + h3: { + fontSize: "1.25rem", + lineHeight: "1.75rem", + fontWeight: 700, + marginTop: "1rem", + }, }, - h3: { - fontSize: "1.25rem", - lineHeight: "1.75rem", - fontWeight: 700, - marginTop: "1rem", + components: { + MuiCard: getMuiCardGlobalStyle(mode), + MuiButton: getMuiButtonGlobalStyle(mode), }, - }, - components: { - MuiCard: MuiCardGlobalStyle, - MuiButton: MuiButtonGlobalStyle, - }, -}); + }); +} + +export const appTheme = createAppTheme("light"); diff --git a/frontend/src/lib/store/themeStore.ts b/frontend/src/lib/store/themeStore.ts new file mode 100644 index 00000000..af66bab9 --- /dev/null +++ b/frontend/src/lib/store/themeStore.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; + +type ThemeMode = "light" | "dark" | "system"; + +type ThemeState = { + mode: ThemeMode; + resolvedMode: "light" | "dark"; + setMode: (mode: ThemeMode) => void; +}; + +function getSystemPreference(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function resolveMode(mode: ThemeMode): "light" | "dark" { + return mode === "system" ? getSystemPreference() : mode; +} + +function loadSavedMode(): ThemeMode { + try { + const saved = localStorage.getItem("theme-mode"); + if (saved === "light" || saved === "dark" || saved === "system") return saved; + } catch { + // localStorage unavailable + } + return "system"; +} + +export const useThemeStore = create((set) => { + const initial = loadSavedMode(); + return { + mode: initial, + resolvedMode: resolveMode(initial), + setMode: (mode) => { + localStorage.setItem("theme-mode", mode); + set({ mode, resolvedMode: resolveMode(mode) }); + }, + }; +}); diff --git a/frontend/src/pages/home/components/LeaderboardTile.tsx b/frontend/src/pages/home/components/LeaderboardTile.tsx index edfd1140..a6a23253 100644 --- a/frontend/src/pages/home/components/LeaderboardTile.tsx +++ b/frontend/src/pages/home/components/LeaderboardTile.tsx @@ -25,8 +25,8 @@ const styles = { }, }, priorityGpuType: { - backgroundColor: "#f5f5f5", - color: "#666", + backgroundColor: "action.hover", + color: "text.secondary", fontSize: "0.9rem", }, topUsersList: { diff --git a/frontend/src/pages/leaderboard/components/RankingLists.tsx b/frontend/src/pages/leaderboard/components/RankingLists.tsx index ac2fa20c..fbc7d1e1 100644 --- a/frontend/src/pages/leaderboard/components/RankingLists.tsx +++ b/frontend/src/pages/leaderboard/components/RankingLists.tsx @@ -8,7 +8,6 @@ import { type Theme, Typography, } from "@mui/material"; -import { grey } from "@mui/material/colors"; import RankingTitleBadge from "./RankingTitleBadge"; import { formatMicroseconds } from "../../../lib/utils/ranking.ts"; @@ -35,7 +34,8 @@ const styles: Record> = { mt: 5, }, rankingRow: { - borderBottom: "1px solid #ddd", + borderBottom: 1, + borderColor: "divider", }, header: { display: "flex", @@ -64,7 +64,7 @@ const styles: Record> = { minWidth: "100px", }, delta: { - color: grey[600], + color: "text.secondary", minWidth: "90px", }, }; diff --git a/frontend/src/pages/lectures/Lectures.tsx b/frontend/src/pages/lectures/Lectures.tsx index a41470f0..c4ccca82 100644 --- a/frontend/src/pages/lectures/Lectures.tsx +++ b/frontend/src/pages/lectures/Lectures.tsx @@ -1,4 +1,5 @@ import { Box, Typography, Link, Chip } from "@mui/material"; +import type { Theme } from "@mui/material/styles"; import { useEffect, useState } from "react"; import { fetchEvents, DiscordEvent } from "../../api/api"; import Loading from "../../components/common/loading"; @@ -16,15 +17,17 @@ const styles = { fontSize: "1.5rem", fontWeight: 600, marginBottom: "16px", - borderBottom: "2px solid #eee", + borderBottom: "2px solid", + borderColor: "divider", paddingBottom: "8px", }, card: { marginBottom: "16px", padding: "16px", - backgroundColor: "#f9f9f9", + backgroundColor: "action.hover", borderRadius: "8px", - border: "1px solid #eee", + border: "1px solid", + borderColor: "divider", }, cardTitle: { fontSize: "1.1rem", @@ -32,37 +35,40 @@ const styles = { marginBottom: "4px", }, cardMeta: { - color: "#666", + color: "text.secondary", marginBottom: "8px", fontSize: "0.9rem", }, cardDescription: { marginBottom: "8px", - color: "#333", + color: "text.primary", }, link: { - color: "#1976d2", + color: "primary.main", textDecoration: "none", "&:hover": { textDecoration: "underline", }, }, archiveBox: { - backgroundColor: "#e3f2fd", + backgroundColor: (theme: Theme) => + theme.palette.mode === "dark" ? "rgba(33, 150, 243, 0.08)" : "#e3f2fd", padding: "16px", borderRadius: "8px", marginTop: "32px", - border: "1px solid #90caf9", + border: "1px solid", + borderColor: (theme: Theme) => + theme.palette.mode === "dark" ? "rgba(144, 202, 249, 0.3)" : "#90caf9", }, intro: { marginBottom: "24px", lineHeight: 1.6, }, noEvents: { - color: "#666", + color: "text.secondary", fontStyle: "italic", padding: "16px", - backgroundColor: "#f5f5f5", + backgroundColor: "action.hover", borderRadius: "8px", }, chipContainer: { @@ -251,14 +257,14 @@ export default function Lectures() { {/* Upcoming Lectures Section */} Upcoming Lectures - + Lectures are pulled live from our Discord server. For the most up-to-date schedule, join the{" "} GPU MODE Discord diff --git a/frontend/src/pages/working-groups/WorkingGroups.tsx b/frontend/src/pages/working-groups/WorkingGroups.tsx index 7cb060bc..fcf20afb 100644 --- a/frontend/src/pages/working-groups/WorkingGroups.tsx +++ b/frontend/src/pages/working-groups/WorkingGroups.tsx @@ -1,4 +1,5 @@ import { Box, Typography, Link } from "@mui/material"; +import type { Theme } from "@mui/material/styles"; const styles = { container: { @@ -13,15 +14,17 @@ const styles = { fontSize: "1.5rem", fontWeight: 600, marginBottom: "16px", - borderBottom: "2px solid #eee", + borderBottom: "2px solid", + borderColor: "divider", paddingBottom: "8px", }, projectCard: { marginBottom: "24px", padding: "16px", - backgroundColor: "#f9f9f9", + backgroundColor: "action.hover", borderRadius: "8px", - border: "1px solid #eee", + border: "1px solid", + borderColor: "divider", }, projectTitle: { fontSize: "1.1rem", @@ -30,10 +33,10 @@ const styles = { }, projectDescription: { marginBottom: "8px", - color: "#333", + color: "text.primary", }, projectLink: { - color: "#1976d2", + color: "primary.main", textDecoration: "none", "&:hover": { textDecoration: "underline", @@ -44,18 +47,24 @@ const styles = { lineHeight: 1.6, }, highlight: { - backgroundColor: "#fff3cd", + backgroundColor: (theme: Theme) => + theme.palette.mode === "dark" ? "rgba(255, 193, 7, 0.08)" : "#fff3cd", padding: "12px 16px", borderRadius: "6px", marginBottom: "24px", - border: "1px solid #ffc107", + border: "1px solid", + borderColor: (theme: Theme) => + theme.palette.mode === "dark" ? "rgba(255, 193, 7, 0.3)" : "#ffc107", }, governance: { - backgroundColor: "#e3f2fd", + backgroundColor: (theme: Theme) => + theme.palette.mode === "dark" ? "rgba(33, 150, 243, 0.08)" : "#e3f2fd", padding: "16px", borderRadius: "8px", marginTop: "32px", - border: "1px solid #90caf9", + border: "1px solid", + borderColor: (theme: Theme) => + theme.palette.mode === "dark" ? "rgba(144, 202, 249, 0.3)" : "#90caf9", }, }; @@ -213,7 +222,7 @@ function ProjectCard({ project }: { project: Project }) { )} {project.note && ( {project.note} @@ -267,7 +276,7 @@ export default function WorkingGroups() { Kernel DSL Customer Support Channels - + Get help and support for popular kernel development DSLs and libraries. diff --git a/frontend/src/tests/test-utils.tsx b/frontend/src/tests/test-utils.tsx index 221a4624..f3d5fa88 100644 --- a/frontend/src/tests/test-utils.tsx +++ b/frontend/src/tests/test-utils.tsx @@ -2,15 +2,16 @@ import { CssBaseline, ThemeProvider } from "@mui/material"; import { render } from "@testing-library/react"; import type { ReactNode } from "react"; import { MemoryRouter } from "react-router-dom"; -import { appTheme } from "../components/common/styles/theme"; +import { createAppTheme } from "../components/common/styles/theme"; export function renderWithRouter(ui: React.ReactElement, options = {}) { return render(ui, { wrapper: MemoryRouter, ...options }); } export function renderWithProviders(ui: ReactNode, route = "/") { + const theme = createAppTheme("light"); return render( - + {ui} , From be18ee74fbbd184599501b2a41b6caa07b5a05ef Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Fri, 6 Feb 2026 20:27:06 -0800 Subject: [PATCH 2/3] Fix dark mode logo and markdown link colors Use a cropped white SVG logo without the black background rect for dark mode. Add theme-aware link color to the markdown renderer so links in news posts use the primary color instead of the harsh default blue. --- frontend/public/gpu-mode-logo/white-cropped.svg | 10 ++++++++++ frontend/src/components/app-layout/NavBar.tsx | 2 +- .../components/markdown-renderer/MarkdownRenderer.tsx | 8 ++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 frontend/public/gpu-mode-logo/white-cropped.svg diff --git a/frontend/public/gpu-mode-logo/white-cropped.svg b/frontend/public/gpu-mode-logo/white-cropped.svg new file mode 100644 index 00000000..e0b66872 --- /dev/null +++ b/frontend/public/gpu-mode-logo/white-cropped.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/components/app-layout/NavBar.tsx b/frontend/src/components/app-layout/NavBar.tsx index f9b45e07..3579144e 100644 --- a/frontend/src/components/app-layout/NavBar.tsx +++ b/frontend/src/components/app-layout/NavBar.tsx @@ -28,7 +28,7 @@ export default function NavBar() { const isDark = theme.palette.mode === "dark"; const logoSrc = isDark - ? "/gpu-mode-logo/white.svg" + ? "/gpu-mode-logo/white-cropped.svg" : "/gpu-mode-logo/black-cropped.svg"; const cycleMode = () => { diff --git a/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx b/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx index a32513d9..fadeb6e6 100644 --- a/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx +++ b/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; +import { useTheme } from "@mui/material/styles"; type MarkdownRendererProps = { content: string; @@ -44,11 +45,18 @@ const MarkdownRenderer: React.FC = ({ }) => { const mergedImageProps = { ...defaultImageProps, ...imageProps }; const { align, ...styleProps } = mergedImageProps; + const theme = useTheme(); return ( ( + + ), figure: ({ node, ...props }) => (
), From 444e8f61f9733187a9c657cc21ae1c9728b0efc7 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Fri, 6 Feb 2026 20:33:14 -0800 Subject: [PATCH 3/3] Fix grey background on individual code lines in leaderboard view Override background on elements from Prism syntax highlighter theme, matching existing
 override.
---
 frontend/src/components/codeblock/CodeBlock.tsx | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/frontend/src/components/codeblock/CodeBlock.tsx b/frontend/src/components/codeblock/CodeBlock.tsx
index b905d317..3a0b3c8e 100644
--- a/frontend/src/components/codeblock/CodeBlock.tsx
+++ b/frontend/src/components/codeblock/CodeBlock.tsx
@@ -69,6 +69,9 @@ export default function CodeBlock({ code }: CodeBlockProps) {
             fontFamily: "monospace !important",
             background: "transparent !important",
           },
+          "& code": {
+            background: "transparent !important",
+          },
         }}
       >