From 0f06b3ffbc2fd9e1fe77b5982a3a2e738b18b9bd Mon Sep 17 00:00:00 2001 From: Daniel Jecu Date: Thu, 2 Jul 2026 08:54:28 +0300 Subject: [PATCH] feat(ui): add dark mode with persistent theme toggle - theme.ts: light/dark variants for all semantic tokens (pageBg, surface, border, text, cardHeader), exposed via getTheme(mode); colors attached to the MUI theme so emotion styled components read them from context - app-provider: ColorModeContext + useColorMode hook, persists choice to localStorage, defaults to OS prefers-color-scheme; CssBaseline now owns body background/text (removed hardcoded values from index.css) - header: moon/sun IconButton to toggle mode - styled files: switched static Theme.colors imports to theme-context callbacks so borders, card headers and surfaces flip with the mode; inline Card style borders moved to sx with borderColor: divider - login: reach-dialog surface themed inline (its stylesheet hardcodes white); drag highlight uses dark green in dark mode for readable text - launch.json: dev server config for browser preview verification Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 13 ++ .../layout/navigation/header-bar/index.tsx | 11 ++ ui/src/index.css | 3 +- .../Home/ProjectsView/components/SideBar.tsx | 7 +- .../Home/ProjectsView/components/styled.ts | 3 +- .../Home/TasklistsView/components/styled.ts | 5 +- .../SimpleTaskCard/SimpleTaskCard.styled.ts | 7 +- .../components/SimpleTaskCard/index.tsx | 6 +- ui/src/pages/Home/TasksView/tasks.page.tsx | 19 +-- ui/src/pages/Home/components/GenericCard.tsx | 5 +- ui/src/pages/Home/components/NewTaskCard.tsx | 6 +- ui/src/pages/Home/components/TaskCard.tsx | 6 +- ui/src/pages/Home/components/styled.ts | 9 +- ui/src/pages/Home/home.style.tsx | 5 +- ui/src/pages/Login/index.tsx | 14 +- ui/src/providers/app-provider.tsx | 51 ++++++- ui/src/styles/emotion.d.ts | 8 ++ ui/src/styles/theme.ts | 126 ++++++++++++------ ui/src/test/test-utils.tsx | 6 +- 19 files changed, 213 insertions(+), 97 deletions(-) create mode 100644 .claude/launch.json create mode 100644 ui/src/styles/emotion.d.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..d630c50 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "ui-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["start"], + "port": 3000, + "autoPort": true, + "cwd": "ui" + } + ] +} diff --git a/ui/src/components/layout/navigation/header-bar/index.tsx b/ui/src/components/layout/navigation/header-bar/index.tsx index e3975a1..3916288 100644 --- a/ui/src/components/layout/navigation/header-bar/index.tsx +++ b/ui/src/components/layout/navigation/header-bar/index.tsx @@ -11,6 +11,9 @@ import AppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import IconButton from "@mui/material/IconButton"; import MenuIcon from "@mui/icons-material/Menu"; +import Brightness4Icon from "@mui/icons-material/Brightness4"; +import Brightness7Icon from "@mui/icons-material/Brightness7"; +import { useColorMode } from "providers/app-provider"; import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; @@ -21,6 +24,7 @@ import Container from "@mui/material/Container"; import Button from "@mui/material/Button"; const NewResponsiveHeader = () => { + const { mode, toggleColorMode } = useColorMode(); const [anchorElNav, setAnchorElNav] = React.useState( null ); @@ -105,6 +109,13 @@ const NewResponsiveHeader = () => { + + {mode === "dark" ? : } + diff --git a/ui/src/index.css b/ui/src/index.css index a425ebb..973bc68 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -3,8 +3,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - background-color: #f4f6f8; - color: #20262b; + /* background/text colors come from the MUI theme via CssBaseline */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/ui/src/pages/Home/ProjectsView/components/SideBar.tsx b/ui/src/pages/Home/ProjectsView/components/SideBar.tsx index 1af7e01..42b8457 100644 --- a/ui/src/pages/Home/ProjectsView/components/SideBar.tsx +++ b/ui/src/pages/Home/ProjectsView/components/SideBar.tsx @@ -1,6 +1,5 @@ import styled from "@emotion/styled"; import React from "react"; -import Theme from "styles/theme"; export const SideBar = (props: any) => ( @@ -16,7 +15,7 @@ export const SideBar = (props: any) => ( const SideBarView = styled.header` float: left; - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; margin-bottom: 25px; padding-right: 10px; overflow: hidden; @@ -26,13 +25,13 @@ const Menu = styled.header` text-align: center; padding-right: 10px; header & div { - background-color: ${Theme.colors.pageBg}; + background-color: ${({ theme }) => theme.colors.pageBg}; padding: 8px; margin-top: 7px; display: block; width: 100%; - color: ${Theme.colors.textPrimary}; + color: ${({ theme }) => theme.colors.textPrimary}; } `; diff --git a/ui/src/pages/Home/ProjectsView/components/styled.ts b/ui/src/pages/Home/ProjectsView/components/styled.ts index 4b26f04..99890ab 100644 --- a/ui/src/pages/Home/ProjectsView/components/styled.ts +++ b/ui/src/pages/Home/ProjectsView/components/styled.ts @@ -1,10 +1,9 @@ import styled from "@emotion/styled"; -import Theme from "styles/theme"; const ProjectCardContainer = styled.div` width: 30vmax; border-radius: 4px; - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; margin: 1.5rem 0.5rem 1.5rem 0.5rem; overflow: hidden; `; diff --git a/ui/src/pages/Home/TasklistsView/components/styled.ts b/ui/src/pages/Home/TasklistsView/components/styled.ts index 47f8779..41e3300 100644 --- a/ui/src/pages/Home/TasklistsView/components/styled.ts +++ b/ui/src/pages/Home/TasklistsView/components/styled.ts @@ -1,16 +1,15 @@ import styled from "@emotion/styled"; -import Theme from "styles/theme"; const TasklistCardContainer = styled.div` width: 100%; - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; border-radius: 4px; margin: 1.5rem 0.5rem 1.5rem 0.5rem; overflow: hidden; `; const CardTitle = styled.div` - background-color: ${Theme.colors.cardHeader}; + background-color: ${({ theme }) => theme.colors.cardHeader}; padding: 0.33rem 1rem 0.33rem 1rem; border-bottom: 1px solid; `; diff --git a/ui/src/pages/Home/TasksView/components/SimpleTaskCard/SimpleTaskCard.styled.ts b/ui/src/pages/Home/TasksView/components/SimpleTaskCard/SimpleTaskCard.styled.ts index 65beb38..f0a4a43 100644 --- a/ui/src/pages/Home/TasksView/components/SimpleTaskCard/SimpleTaskCard.styled.ts +++ b/ui/src/pages/Home/TasksView/components/SimpleTaskCard/SimpleTaskCard.styled.ts @@ -1,5 +1,4 @@ import styled from "@emotion/styled/macro"; -import Theme from "styles/theme"; // import styled from "@emotion/styled"; // import { MyThemeType } from "styles/theme"; // import { styled, alpha } from "@mui/material/styles"; @@ -7,14 +6,14 @@ import Theme from "styles/theme"; const ProjectCardContainer = styled.div` width: 30vmax; - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; margin: 1.5rem 0.5rem 1.5rem 0.5rem; overflow: hidden; `; const TasklistCardContainer = styled.div` width: 30vmax; - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; margin: 1.5rem 0.5rem 1.5rem 0.5rem; overflow: hidden; `; @@ -40,7 +39,7 @@ const CardTitle = styled.div` const CardCreateTitle = styled.div` display: flex; flex-direction: column; - background-color: ${Theme.colors.cardHeader}; + background-color: ${({ theme }) => theme.colors.cardHeader}; padding: 0.33rem 0 0.33rem 1rem; border-bottom: 1px solid; `; diff --git a/ui/src/pages/Home/TasksView/components/SimpleTaskCard/index.tsx b/ui/src/pages/Home/TasksView/components/SimpleTaskCard/index.tsx index afdd0cf..dee43dc 100644 --- a/ui/src/pages/Home/TasksView/components/SimpleTaskCard/index.tsx +++ b/ui/src/pages/Home/TasksView/components/SimpleTaskCard/index.tsx @@ -11,7 +11,6 @@ import { Delete, Build, Save } from "@mui/icons-material"; import { Paper, Card } from "@mui/material"; import Typography from "@mui/material/Typography"; -import Theme from "styles/theme"; // interface TaskProps { // id: number; @@ -76,8 +75,9 @@ export const SimpleTaskCard = ({ return ( theme.colors.border}; + background-color: ${({ theme }) => theme.colors.surface}; border-radius: 2px; `; @@ -236,16 +235,20 @@ interface StyledDivProps { isDragging?: boolean; } const TaskContainer = styled.div` - border: 1px solid ${Theme.colors.border}; + border: 1px solid ${({ theme }) => theme.colors.border}; padding: 8px; margin-bottom: 8px; - background-color: ${(props) => - props.isDragging ? Theme.colors.grassGreenLight : Theme.colors.surface}; + background-color: ${({ theme, isDragging }) => + isDragging + ? theme.palette.mode === "dark" + ? theme.colors.grassGreenDark + : theme.colors.grassGreenLight + : theme.colors.surface}; &:focus { outline: none; - border-color: ${Theme.colors.ocean}; + border-color: ${({ theme }) => theme.colors.ocean}; } `; @@ -277,7 +280,7 @@ const ColumnsRowContainer = styled.div` `; const CardTitle = styled.div` - background-color: ${Theme.colors.cardHeader}; + background-color: ${({ theme }) => theme.colors.cardHeader}; padding: 0.33rem 0 0.33rem 1rem; border-bottom: 1px solid; `; diff --git a/ui/src/pages/Home/components/GenericCard.tsx b/ui/src/pages/Home/components/GenericCard.tsx index 3d457df..4328a68 100644 --- a/ui/src/pages/Home/components/GenericCard.tsx +++ b/ui/src/pages/Home/components/GenericCard.tsx @@ -2,7 +2,6 @@ import React from "react"; import { Link as ReactLink } from "react-router-dom"; import Link from "@mui/material/Link"; import styled from "@emotion/styled/macro"; -import Theme from "styles/theme"; interface CardProps { id: number; @@ -42,13 +41,13 @@ export const GenericCard: React.FC = ({ const CardContainer = styled.div` width: 30vmax; - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; margin: 1.5rem 0.5rem 1.5rem 0.5rem; overflow: hidden; `; const CardTitle = styled.div` - background-color: ${Theme.colors.cardHeader}; + background-color: ${({ theme }) => theme.colors.cardHeader}; padding: 0.33rem 0 0.33rem 1rem; border-bottom: 1px solid; `; diff --git a/ui/src/pages/Home/components/NewTaskCard.tsx b/ui/src/pages/Home/components/NewTaskCard.tsx index 65ec22e..5e01ae1 100644 --- a/ui/src/pages/Home/components/NewTaskCard.tsx +++ b/ui/src/pages/Home/components/NewTaskCard.tsx @@ -11,7 +11,6 @@ import { Delete, Build, Save } from "@mui/icons-material"; import { Paper, Card } from "@mui/material"; import Typography from "@mui/material/Typography"; -import Theme from "styles/theme"; interface TaskProps { id: number; @@ -77,8 +76,9 @@ export const ViewTaskCard = ({ return ( theme.colors.border}; margin: 1.5rem 0.5rem 1.5rem 0.5rem; overflow: hidden; `; const TasklistCardContainer = styled.div` width: 30vmax; - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; margin: 1.5rem 0.5rem 1.5rem 0.5rem; overflow: hidden; `; const TaskContainer = styled.div` width: 30vmax; - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; border-radius: 4px; margin: 0.5rem 0.2rem; overflow: hidden; @@ -31,7 +30,7 @@ const CardTitle = styled.div` const CardCreateTitle = styled.div` display: flex; flex-direction: column; - background-color: ${Theme.colors.cardHeader}; + background-color: ${({ theme }) => theme.colors.cardHeader}; padding: 0.33rem 0 0.33rem 1rem; border-bottom: 1px solid; `; diff --git a/ui/src/pages/Home/home.style.tsx b/ui/src/pages/Home/home.style.tsx index d0547a7..1fc34fa 100644 --- a/ui/src/pages/Home/home.style.tsx +++ b/ui/src/pages/Home/home.style.tsx @@ -1,15 +1,14 @@ import styled from "@emotion/styled"; -import Theme from "styles/theme"; export const MainWrapper = styled.main` - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; margin-bottom: 25px; padding-right: 10px; overflow: hidden; `; export const MainContent = styled.main` - border: 2px solid ${Theme.colors.border}; + border: 2px solid ${({ theme }) => theme.colors.border}; margin-bottom: 25px; padding-right: 10px; overflow: hidden; diff --git a/ui/src/pages/Login/index.tsx b/ui/src/pages/Login/index.tsx index a420c96..7dbd1de 100644 --- a/ui/src/pages/Login/index.tsx +++ b/ui/src/pages/Login/index.tsx @@ -7,6 +7,7 @@ import { Logo } from "../../components/logo"; import { Spinner, FormGroup, CircleButton } from "../../components/lib"; import { TextField } from "@mui/material"; import { display } from "@mui/system"; +import { useTheme } from "@mui/material/styles"; import Button from "@mui/material/Button"; import { AxiosResponse } from "axios"; import { IUserSession } from "interfaces/users"; @@ -79,9 +80,18 @@ interface LoginPropsInterface { } export const Login = ({ login, register }: LoginPropsInterface) => { + const theme = useTheme(); const [openModal, setOpenModal] = React.useState("none"); const [error, setError] = React.useState(""); + // @reach/dialog ships a fixed white background — inline style wins the + // cascade, so theme the dialog surface here for dark mode support. + const dialogStyle: React.CSSProperties = { + borderRadius: "4px", + background: theme.palette.background.paper, + color: theme.palette.text.primary, + }; + const handleLogin = (formData: UserCredentialsFormDataType) => { console.log("login", formData); const loginToastId = toast.loading("Please wait...", { @@ -175,7 +185,7 @@ export const Login = ({ login, register }: LoginPropsInterface) => { {openModal === "login" && ( @@ -191,7 +201,7 @@ export const Login = ({ login, register }: LoginPropsInterface) => { )} {openModal === "register" && ( diff --git a/ui/src/providers/app-provider.tsx b/ui/src/providers/app-provider.tsx index a158bc1..2259e59 100644 --- a/ui/src/providers/app-provider.tsx +++ b/ui/src/providers/app-provider.tsx @@ -6,22 +6,59 @@ import { AuthProvider } from "../contexts/auth"; import "react-toastify/dist/ReactToastify.min.css"; import { ToastContainer } from "react-toastify"; -import { ThemeProvider, createTheme } from "@mui/material/styles"; -import Theme from "styles/theme"; -const theme = createTheme(Theme); +import { ThemeProvider } from "@mui/material/styles"; +import { PaletteMode } from "@mui/material"; +import CssBaseline from "@mui/material/CssBaseline"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { getTheme } from "styles/theme"; + +const COLOR_MODE_STORAGE_KEY = "todos-color-mode"; + +export const ColorModeContext = React.createContext<{ + mode: PaletteMode; + toggleColorMode: () => void; +}>({ mode: "light", toggleColorMode: () => {} }); + +export const useColorMode = () => React.useContext(ColorModeContext); type AppProviderProps = { children: React.ReactNode; }; export const AppProvider = ({ children }: AppProviderProps) => { + const prefersDark = useMediaQuery("(prefers-color-scheme: dark)"); + const [mode, setMode] = React.useState(() => { + const stored = localStorage.getItem(COLOR_MODE_STORAGE_KEY); + if (stored === "light" || stored === "dark") return stored; + return prefersDark ? "dark" : "light"; + }); + + const colorMode = React.useMemo( + () => ({ + mode, + toggleColorMode: () => { + setMode((prev) => { + const next = prev === "light" ? "dark" : "light"; + localStorage.setItem(COLOR_MODE_STORAGE_KEY, next); + return next; + }); + }, + }), + [mode] + ); + + const theme = React.useMemo(() => getTheme(mode), [mode]); + return ( Loading... }> - - {children} - - + + + + {children} + + + ); diff --git a/ui/src/styles/emotion.d.ts b/ui/src/styles/emotion.d.ts new file mode 100644 index 0000000..89e8991 --- /dev/null +++ b/ui/src/styles/emotion.d.ts @@ -0,0 +1,8 @@ +import "@emotion/react"; +import { Theme as MuiTheme } from "@mui/material/styles"; + +// MUI's ThemeProvider injects its theme into emotion's context, so emotion +// styled components receive the full MUI theme (including `colors`). +declare module "@emotion/react" { + export interface Theme extends MuiTheme {} +} diff --git a/ui/src/styles/theme.ts b/ui/src/styles/theme.ts index d7cb26a..cb958fa 100644 --- a/ui/src/styles/theme.ts +++ b/ui/src/styles/theme.ts @@ -1,9 +1,25 @@ -import { PaletteColorOptions } from "@mui/material"; -import { PaletteOptions } from "@mui/material/styles"; +import { PaletteColorOptions, PaletteMode } from "@mui/material"; +import { + createTheme, + PaletteOptions, + Theme as MuiTheme, +} from "@mui/material/styles"; import { IPropsColor } from "interfaces"; import { TypeText, TypeBackground } from "@mui/material/styles/createPalette"; -const colors: IPropsColor = { +// Expose the semantic color tokens on the MUI theme so emotion styled +// components can read them from context and react to mode changes. +declare module "@mui/material/styles" { + interface Theme { + colors: IPropsColor; + } + interface ThemeOptions { + colors?: IPropsColor; + } +} + +// Brand colors — identical in both modes +const brand = { navy: "#013d54", navyLight: "#3a6781", navyDark: "#00172b", @@ -19,7 +35,11 @@ const colors: IPropsColor = { greyCool: "#E5E6E5", greyCloudy: "#c9c9c8", greyCharcoal: "#363936", - // Semantic tokens for surfaces/text (light theme) +}; + +// Semantic tokens for surfaces/text, per mode +const lightColors: IPropsColor = { + ...brand, pageBg: "#f4f6f8", surface: "#ffffff", border: "#d5d8dc", @@ -28,52 +48,74 @@ const colors: IPropsColor = { cardHeader: "#e3f1fb", }; -const primary: PaletteColorOptions = { - main: colors.ocean, - contrastText: "#ffffff", +const darkColors: IPropsColor = { + ...brand, + pageBg: "#10161a", + surface: "#1c242b", + border: "#3b464f", + textPrimary: "#e6e9eb", + textSecondary: "#9fa8ae", + cardHeader: "#16324a", }; -const secondary: PaletteColorOptions = { - main: colors.tangerine, - contrastText: "#ffffff", -}; +export const getColors = (mode: PaletteMode): IPropsColor => + mode === "dark" ? darkColors : lightColors; -const warning: PaletteColorOptions = { - main: colors.tangerineLight, - contrastText: colors.greyCharcoal, -}; +const getPalette = (mode: PaletteMode): PaletteOptions => { + const colors = getColors(mode); -const success: PaletteColorOptions = { - main: colors.grassGreen, - contrastText: colors.greyCharcoal, -}; + const primary: PaletteColorOptions = { + main: colors.ocean, + contrastText: "#ffffff", + }; -const info: PaletteColorOptions = { - main: colors.navy, - contrastText: "#ffffff", -}; -const text: Partial = { - primary: colors.textPrimary, - secondary: colors.textSecondary, -}; + const secondary: PaletteColorOptions = { + main: colors.tangerine, + contrastText: "#ffffff", + }; -const background: Partial = { - default: colors.pageBg, - paper: colors.surface, -}; + const warning: PaletteColorOptions = { + main: colors.tangerineLight, + contrastText: colors.greyCharcoal, + }; + + const success: PaletteColorOptions = { + main: colors.grassGreen, + contrastText: colors.greyCharcoal, + }; + + const info: PaletteColorOptions = { + main: mode === "dark" ? colors.navyLight : colors.navy, + contrastText: "#ffffff", + }; -const palette: PaletteOptions = { - mode: "light", - primary, - secondary, - warning, - success, - info, - text, - background, - divider: colors.border, + const text: Partial = { + primary: colors.textPrimary, + secondary: colors.textSecondary, + }; + + const background: Partial = { + default: colors.pageBg, + paper: colors.surface, + }; + + return { + mode, + primary, + secondary, + warning, + success, + info, + text, + background, + divider: colors.border, + }; }; -const Theme = { colors, palette }; +export const getTheme = (mode: PaletteMode): MuiTheme => + createTheme({ palette: getPalette(mode), colors: getColors(mode) }); + +// Back-compat default export: light-mode tokens/palette +const Theme = { colors: lightColors, palette: getPalette("light") }; export default Theme; diff --git a/ui/src/test/test-utils.tsx b/ui/src/test/test-utils.tsx index b49026a..cfbdba0 100644 --- a/ui/src/test/test-utils.tsx +++ b/ui/src/test/test-utils.tsx @@ -1,9 +1,9 @@ import React, { FC, ReactElement } from "react"; import { render, RenderOptions } from "@testing-library/react"; -import { ThemeProvider, createTheme } from "@mui/material/styles"; -import Theme from "styles/theme"; +import { ThemeProvider } from "@mui/material/styles"; +import { getTheme } from "styles/theme"; -const theme = createTheme(Theme); +const theme = getTheme("light"); const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => { return {children};