From a1ff2c692c62881c4aa0e0b4c0d1ecd161d49c53 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 23 May 2026 11:41:57 +0000 Subject: [PATCH] Theme System Refactor (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Dashboard State Lift + Theme System Refactor ## Summary Refactored dashboard state ownership, centralized theme semantics, and simplified component styling across the application. ## Changes ### Dashboard State Refactor * Moved dashboard state management from `Dashboard.view` into `Dashboard.tsx` * Added centralized `DashboardState` initialization in parent container * Introduced memoized dashboard state setter callbacks: * `toggleFlow` * `setFlow` * `togglePeriodType` * `toggleComparison` * `setSelectedPeriodId` * `setSelectedGroupKey` * Added `DashboardStateSetters` memoized object for prop-driven state management * Removed `onFlowChange` callback pattern * Converted dashboard component into stateless view layer * Renamed component export flow: * `Dashboard.tsx` → removed * `Dashboard.view.tsx` → primary implementation ### Dashboard Models Cleanup * Removed legacy palette configuration interfaces: * `ColorDefinition` * `ThemeAwarePalette` * Removed config-level style palette support from `DashboardConfig` * Renamed `DashboardProps` → `DashboardViewProps` * Added reusable `ColorScheme` interface * Simplified component color contract: * `primary` * `surface` * `text` ### Theme Architecture Refactor * Moved `AppTheme.tsx` into `shared-theme` * Added centralized semantic theme system * Introduced `themeConfig.ts` with semantic tokens: * surface * border * text * Added `semantic` extension to MUI theme typing * Added `flows` palette extension: * outflows * inflows * Centralized flow colors inside theme primitives * Added CSS semantic variables: * `--bg-page` * `--bg-card` * `--bg-elevated` * `--border-default` * `--border-subtle` * `--text-primary` * `--text-secondary` * `--text-muted` ### Theme Mode Improvements * Added explicit `ColorMode` type * Expanded `ColorModeContext`: * `mode` * `setMode` * `toggleColorMode` * Added `CssBaseline` * Added configurable `defaultMode` * Simplified dark theme palette handling * Standardized dark surfaces and shadows * Reduced excessive dark-mode glow/shadow intensity ### Dashboard UI Styling Improvements * Replaced hardcoded dashboard palette config with theme palette usage * Updated dashboard background gradients to use alpha-based semantic colors * Replaced `colorScheme.light` usage with `colorScheme.surface` * Standardized border usage with theme divider tokens * Removed manual dark-mode conditional styling where redundant * Simplified card and progress styling logic ### Shared Theme Customization Cleanup Updated customization layers for improved consistency: * `inputs` * `navigation` * `feedback` * `surfaces` Key improvements: * Reduced dark-mode contrast harshness * Unified divider usage * Replaced hardcoded grayscale backgrounds with semantic surfaces * Simplified hover and active state styling * Reduced shadow intensity across components * Improved dark-mode readability and layering ### Home Page Styling Cleanup * Replaced manual RGBA handling with `alpha()` utility * Improved dark-mode glassmorphism consistency * Updated CTA hover shadow to use theme primary color ### Miscellaneous Cleanup * Updated imports to new theme structure * Removed unused legacy color mode components: * `ColorModeIconDropdown.tsx` * `ColorModeSelect.tsx` * Removed dashboard config style palette definitions * Simplified flow-based color derivation logic ## Result * Cleaner separation of stateful vs presentational dashboard logic * Centralized semantic theming system * Consistent dark/light mode behavior * Reduced styling duplication * Improved maintainability and extensibility of theme architecture * Simplified dashboard component contracts * Better UI consistency across surfaces and controls Reviewed-on: https://git.aetoskia.com/apps/khata-ui/pulls/6 Co-authored-by: Vishesh 'ironeagle' Bangotra Co-committed-by: Vishesh 'ironeagle' Bangotra --- src/AppTheme.tsx | 48 ----- src/Dashboard.tsx | 149 +++++++++++-- src/Header.tsx | 2 +- src/Home.tsx | 13 +- src/components/Dashboard/Dashboard.models.ts | 30 +-- src/components/Dashboard/Dashboard.tsx | 202 ------------------ src/components/Dashboard/Dashboard.view.tsx | 105 +++++++++ src/components/Dashboard/index.ts | 2 +- .../HistoryChart/HistoryChart.view.tsx | 2 +- .../LatestItems/LatestItems.view.tsx | 3 +- .../ProgressCard/ProgressCard.view.tsx | 14 +- src/dashboard-config.ts | 28 --- src/main.jsx | 2 +- src/shared-theme/AppTheme.tsx | 144 +++++++++---- src/shared-theme/ColorModeIconDropdown.tsx | 89 -------- src/shared-theme/ColorModeSelect.tsx | 28 --- src/shared-theme/customizations/feedback.tsx | 4 +- src/shared-theme/customizations/inputs.tsx | 30 +-- .../customizations/navigation.tsx | 20 +- src/shared-theme/customizations/surfaces.ts | 6 +- src/shared-theme/themeConfig.ts | 72 +++++++ src/shared-theme/themePrimitives.ts | 139 ++++++++---- 22 files changed, 559 insertions(+), 573 deletions(-) delete mode 100644 src/AppTheme.tsx delete mode 100644 src/components/Dashboard/Dashboard.tsx create mode 100644 src/components/Dashboard/Dashboard.view.tsx delete mode 100644 src/shared-theme/ColorModeIconDropdown.tsx delete mode 100644 src/shared-theme/ColorModeSelect.tsx create mode 100644 src/shared-theme/themeConfig.ts diff --git a/src/AppTheme.tsx b/src/AppTheme.tsx deleted file mode 100644 index 288cd45..0000000 --- a/src/AppTheme.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import * as React from "react"; -import { ThemeProvider, createTheme } from "@mui/material/styles"; -import { getDesignTokens } from "./shared-theme/themePrimitives"; -import { inputsCustomizations } from "./shared-theme/customizations/inputs"; -import { dataDisplayCustomizations } from "./shared-theme/customizations/dataDisplay"; -import { feedbackCustomizations } from "./shared-theme/customizations/feedback"; -import { navigationCustomizations } from "./shared-theme/customizations/navigation"; -import { surfacesCustomizations } from "./shared-theme/customizations/surfaces"; - -export const ColorModeContext = React.createContext({ - toggleColorMode: () => {}, - mode: "light" as "light" | "dark", -}); - -export default function AppTheme({ children }: { children: React.ReactNode }) { - const [mode, setMode] = React.useState<"light" | "dark">("light"); - - const colorMode = React.useMemo( - () => ({ - toggleColorMode: () => { - setMode((prevMode) => (prevMode === "light" ? "dark" : "light")); - }, - mode, - }), - [mode] - ); - - const theme = React.useMemo( - () => - createTheme({ - ...getDesignTokens(mode), - components: { - ...inputsCustomizations, - ...dataDisplayCustomizations, - ...feedbackCustomizations, - ...navigationCustomizations, - ...surfacesCustomizations, - }, - }), - [mode] - ); - - return ( - - {children} - - ); -} diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index ffcf1e9..04a5795 100644 --- a/src/Dashboard.tsx +++ b/src/Dashboard.tsx @@ -10,8 +10,14 @@ import { Button } from "@mui/material"; -import ConfigurableDashboard from "./components/Dashboard"; -import { DashboardState } from "./components/Dashboard"; +import DashboardView from "./components/Dashboard"; + +import { + DashboardState, + DashboardStateSetters, + DashboardFlow, +} from "./components/Dashboard"; + import { configuration } from "./dashboard-config"; import { useReport, @@ -19,7 +25,13 @@ import { } from "./features/report"; export default function Dashboard() { - const [flow, setFlow] = React.useState<"outflows" | "inflows">("outflows"); + const [state, setState] = React.useState({ + flow: "outflows", + periodType: "rolling", + selectedPeriodId: null, + selectedGroupKey: null, + comparison: false, + }); const [appliedPayees, setAppliedPayees] = React.useState([]); const [appliedTags, setAppliedTags] = React.useState([]); @@ -32,7 +44,7 @@ export default function Dashboard() { const report = useReport({ periods: ["daily", "weekly", "monthly", "all"], - flow: flow, + flow: state.flow, payee: appliedPayees.length > 0 ? appliedPayees : undefined, tags: appliedTags.length > 0 ? appliedTags : undefined, }); @@ -69,14 +81,124 @@ export default function Dashboard() { } }, [report.data?.data]); + const toggleFlow = + React.useCallback(() => { + setState((prev) => ({ + ...prev, + + flow: + prev.flow === + "outflows" + ? "inflows" + : "outflows", + + selectedGroupKey: + null, + + selectedPeriodId: + null, + })); + }, []); + + const setFlow = + React.useCallback( + ( + flow: DashboardFlow + ) => { + setState((prev) => ({ + ...prev, + + flow, + + selectedGroupKey: + null, + + selectedPeriodId: + null, + })); + }, + [] + ); + + const togglePeriodType = + React.useCallback(() => { + setState((prev) => ({ + ...prev, + + periodType: + prev.periodType === + "rolling" + ? "calendar" + : "rolling", + })); + }, []); + + const toggleComparison = + React.useCallback(() => { + setState((prev) => ({ + ...prev, + + comparison: + !prev.comparison, + })); + }, []); + + const setSelectedPeriodId = + React.useCallback( + ( + selectedPeriodId: DashboardState["selectedPeriodId"] + ) => { + setState((prev) => ({ + ...prev, + + selectedPeriodId, + })); + }, + [] + ); + + const setSelectedGroupKey = + React.useCallback( + ( + selectedGroupKey: DashboardState["selectedGroupKey"] + ) => { + setState((prev) => ({ + ...prev, + + selectedGroupKey, + })); + }, + [] + ); + + const stateSetters: DashboardStateSetters = + React.useMemo( + () => ({ + toggleFlow, + + setFlow, + + togglePeriodType, + + toggleComparison, + + setSelectedPeriodId, + + setSelectedGroupKey, + }), + [ + toggleFlow, + setFlow, + togglePeriodType, + toggleComparison, + setSelectedPeriodId, + setSelectedGroupKey, + ] + ); + const isLoading = report.isLoading; const error = report.error; - /** Callback for the ConfigurableDashboard's flow toggle */ - const handleFlowChange = React.useCallback((newState: DashboardState) => { - setFlow(newState.flow); - }, []); - if (isLoading && !report.data) { return ( @@ -143,8 +265,8 @@ export default function Dashboard() { sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }} /> - - ); diff --git a/src/Header.tsx b/src/Header.tsx index 58ad72b..d7aca2c 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -20,7 +20,7 @@ import DarkModeIcon from "@mui/icons-material/DarkMode"; import LightModeIcon from "@mui/icons-material/LightMode"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../react-auth"; -import { ColorModeContext } from "./AppTheme"; +import { ColorModeContext } from "./shared-theme/AppTheme"; interface HeaderProps { routerMapping: { diff --git a/src/Home.tsx b/src/Home.tsx index 356a295..e4aa1a7 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -1,10 +1,12 @@ import * as React from "react"; import { Box, Typography, Button, Container, Stack } from "@mui/material"; +import { useTheme, alpha } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; export default function Home() { const navigate = useNavigate(); + const theme = useTheme(); return ( - theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.03)" : "rgba(255, 255, 255, 0.6)", + backgroundColor: (t) => alpha(t.palette.common.white, t.palette.mode === "dark" ? 0.04 : 0.6), border: "1px solid", borderColor: "divider", borderRadius: 4, - boxShadow: (theme) => - theme.palette.mode === "dark" - ? "0 8px 32px 0 rgba(0, 0, 0, 0.37)" + boxShadow: (t) => + t.palette.mode === "dark" + ? "0 8px 32px 0 rgba(0, 0, 0, 0.5)" : "0 8px 32px 0 rgba(31, 38, 135, 0.07)", }} > @@ -94,7 +95,7 @@ export default function Home() { transition: "transform 0.2s ease-in-out, box-shadow 0.2s", "&:hover": { transform: "translateY(-3px)", - boxShadow: "0 8px 20px rgba(236,72,153,0.4)", + boxShadow: (t) => `0 8px 20px ${alpha(t.palette.primary.main, 0.4)}`, }, }} > diff --git a/src/components/Dashboard/Dashboard.models.ts b/src/components/Dashboard/Dashboard.models.ts index 5ec4cf3..9c7c625 100644 --- a/src/components/Dashboard/Dashboard.models.ts +++ b/src/components/Dashboard/Dashboard.models.ts @@ -32,31 +32,23 @@ export interface DashboardSection { settings?: Record; } -export interface ColorDefinition { - primary: string; - background: string; - text: string; -} - -export interface ThemeAwarePalette { - light: ColorDefinition; - dark: ColorDefinition; -} - export interface DashboardConfig { sections: DashboardSection[]; - style: { - palette: Record; - }; } -export interface DashboardProps { +export interface DashboardViewProps { config: DashboardConfig; data: ReportData; + state: DashboardState; + stateSetters: DashboardStateSetters; isFetching: boolean; - onFlowChange?: (state: DashboardState) => void; } +export interface ColorScheme { + primary: string; + surface: string; + text: string; +} export interface ComponentProps extends DashboardSection { reportData: ReportData; @@ -65,9 +57,5 @@ export interface ComponentProps extends DashboardSection { stateSetters: DashboardStateSetters; isFetching: boolean; - colorScheme: { - primary: string; - light: string; - text: string; - }; + colorScheme: ColorScheme; } diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx deleted file mode 100644 index 8ca2035..0000000 --- a/src/components/Dashboard/Dashboard.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import * as React from "react"; -import { - Box, - Container, - Grid, - ToggleButton, - ToggleButtonGroup, - Button -} from "@mui/material"; -import { useTheme, alpha } from "@mui/material/styles"; -import { DashboardProps, DashboardState, DashboardStateSetters, DashboardFlow } from "./Dashboard.models"; - -export default function Dashboard({ - config, - data, - isFetching, - onFlowChange, -}: DashboardProps) { - const theme = useTheme(); - const themeMode = theme.palette.mode; - - const [state, setState] = React.useState({ - flow: "outflows", - periodType: "rolling", - selectedPeriodId: null, - selectedGroupKey: null, - comparison: false, - }); - - const toggleFlow = () => { - setState(prev => { - const nextFlow: DashboardFlow = prev.flow === "outflows" ? "inflows" : "outflows"; - const nextState: DashboardState = { - ...prev, - flow: nextFlow, - selectedGroupKey: null, - selectedPeriodId: null, - }; - onFlowChange?.(nextState); - return nextState; - }); - }; - - const handleFlowChange = ( - _event: React.MouseEvent, - newFlow: DashboardFlow | null - ) => { - if (newFlow !== null && newFlow !== state.flow) { - setState(prev => { - const nextState: DashboardState = { - ...prev, - flow: newFlow, - selectedGroupKey: null, - selectedPeriodId: null, - }; - onFlowChange?.(nextState); - return nextState; - }); - } - }; - - const togglePeriodType = () => { - setState(prev => ({ - ...prev, - periodType: prev.periodType === "rolling" ? "calendar" : "rolling", - })); - }; - - const toggleComparison = () => { - setState(prev => ({ - ...prev, - comparison: !prev.comparison, - })); - }; - - const setSelectedPeriodId = (selectedPeriodId: typeof state.selectedPeriodId) => { - setState(prev => ({ ...prev, selectedPeriodId })); - }; - - const setSelectedGroupKey = (groupKey: typeof state.selectedGroupKey) => { - setState(prev => ({ ...prev, selectedGroupKey: groupKey })); - }; - - const stateSetters: DashboardStateSetters = { - togglePeriodType, - toggleComparison, - toggleFlow, - setSelectedPeriodId, - setSelectedGroupKey, - }; - - const { flow, selectedGroupKey } = state; - - const colors = React.useMemo(() => { - const palette = config.style.palette[flow]; - const modeColors = palette[themeMode]; - - return { - primary: modeColors.primary, - light: modeColors.background || alpha(modeColors.primary, 0.1), - text: - modeColors.text || - (themeMode === "light" ? theme.palette.text.primary : "#fff"), - }; - - // if (modeColors) { - // return { - // primary: modeColors.primary, - // light: modeColors.background || alpha(modeColors.primary, 0.1), - // text: - // modeColors.text || - // (themeMode === "light" ? theme.palette.text.primary : "#fff"), - // }; - // } - // - // const themeColor = - // flow === "outflows" ? theme.palette.error : theme.palette.success; - // - // return { - // primary: themeColor.main, - // light: alpha(themeColor.main, themeMode === "light" ? 0.08 : 0.15), - // text: themeColor.main, - // }; - }, [config.style?.palette, flow, themeMode, theme.palette]); - - return ( - - - - Outflows - Inflows - - - {selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && ( - - )} - - - - {config.sections.map((section) => { - const Component = section.component; - - return ( - - - - ); - })} - - - ); -} diff --git a/src/components/Dashboard/Dashboard.view.tsx b/src/components/Dashboard/Dashboard.view.tsx new file mode 100644 index 0000000..2ba5427 --- /dev/null +++ b/src/components/Dashboard/Dashboard.view.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import { + Box, + Container, + Grid, + ToggleButton, + ToggleButtonGroup, + Button +} from "@mui/material"; +import { useTheme, alpha } from "@mui/material/styles"; +import { DashboardViewProps } from "./Dashboard.models"; + +export default function DashboardView({ + config, + data, + state, + stateSetters, + isFetching, +}: DashboardViewProps) { + const theme = useTheme(); + + const { + flow, + selectedGroupKey, + } = state; + + const colorScheme = flow === "outflows" ? theme.palette.flows.outflows : theme.palette.flows.inflows; + + return ( + + + + Outflows + Inflows + + + {selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && ( + + )} + + + + {config.sections.map((section) => { + const Component = section.component; + + return ( + + + + ); + })} + + + ); +} diff --git a/src/components/Dashboard/index.ts b/src/components/Dashboard/index.ts index 2f01984..892b9c4 100644 --- a/src/components/Dashboard/index.ts +++ b/src/components/Dashboard/index.ts @@ -1,2 +1,2 @@ -export { default } from "./Dashboard"; +export { default } from "./Dashboard.view"; export * from "./Dashboard.models"; diff --git a/src/components/HistoryChart/HistoryChart.view.tsx b/src/components/HistoryChart/HistoryChart.view.tsx index c02923c..954ca69 100644 --- a/src/components/HistoryChart/HistoryChart.view.tsx +++ b/src/components/HistoryChart/HistoryChart.view.tsx @@ -76,7 +76,7 @@ export default function HistoryChartView({ boxShadow: "none", border: "1px solid", borderColor: "divider", - bgcolor: isDark ? "background.paper" : colorScheme.light, + bgcolor: isDark ? "background.paper" : colorScheme.surface, opacity: isFetching ? 0.6 : 1, transition: "opacity 0.3s ease", pointerEvents: isFetching ? "none" : "auto", diff --git a/src/components/LatestItems/LatestItems.view.tsx b/src/components/LatestItems/LatestItems.view.tsx index 2932573..50b3d00 100644 --- a/src/components/LatestItems/LatestItems.view.tsx +++ b/src/components/LatestItems/LatestItems.view.tsx @@ -9,6 +9,7 @@ import { Box, IconButton, } from "@mui/material"; +import { alpha } from "@mui/material/styles"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { LatestItemsViewProps } from "./LatestItems.props"; @@ -46,7 +47,7 @@ export default function LatestItemsView({ {title} @@ -83,7 +80,6 @@ export default function ProgressCardView({ sx={{ mb: 0.5, lineHeight: 1.2, - textShadow: isDark ? "0 2px 4px rgba(0,0,0,0.3)" : "none", }} > {formattedProgress} @@ -92,7 +88,7 @@ export default function ProgressCardView({ @@ -118,7 +114,7 @@ export default function ProgressCardView({ height: settings.compact ? 6 : 10, borderRadius: 5, [`&.${linearProgressClasses.colorPrimary}`]: { - backgroundColor: isDark ? "rgba(255, 255, 255, 0.12)" : "rgba(0, 0, 0, 0.08)", + backgroundColor: alpha(theme.palette.divider, 0.5), }, [`& .${linearProgressClasses.bar}`]: { borderRadius: 5, diff --git a/src/dashboard-config.ts b/src/dashboard-config.ts index 43dc137..af42bad 100644 --- a/src/dashboard-config.ts +++ b/src/dashboard-config.ts @@ -37,32 +37,4 @@ export const configuration: DashboardConfig = { component: LatestItems, }, ], - style: { - palette: { - outflows: { - light: { - primary: "#d32f2f", - background: "#fdecea", - text: "#b71c1c" - }, - dark: { - primary: "#f44336", - background: "rgba(244, 67, 54, 0.15)", - text: "#ffcdd2" - } - }, - inflows: { - light: { - primary: "#2e7d32", - background: "#e8f5e9", - text: "#1b5e20" - }, - dark: { - primary: "#4caf50", - background: "rgba(76, 175, 80, 0.15)", - text: "#c8e6c9" - } - } - } - } }; diff --git a/src/main.jsx b/src/main.jsx index cf64145..503363c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -19,7 +19,7 @@ import process from 'process'; import { AuthProvider } from "../react-auth"; import Header from './Header'; import Footer from './Footer'; -import AppTheme from './AppTheme'; +import AppTheme from './shared-theme/AppTheme'; window.Buffer = Buffer; window.process = process; diff --git a/src/shared-theme/AppTheme.tsx b/src/shared-theme/AppTheme.tsx index a4a512c..599ef42 100644 --- a/src/shared-theme/AppTheme.tsx +++ b/src/shared-theme/AppTheme.tsx @@ -1,53 +1,103 @@ -import * as React from 'react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import type { ThemeOptions } from '@mui/material/styles'; -import { inputsCustomizations } from './customizations/inputs'; -import { dataDisplayCustomizations } from './customizations/dataDisplay'; -import { feedbackCustomizations } from './customizations/feedback'; -import { navigationCustomizations } from './customizations/navigation'; -import { surfacesCustomizations } from './customizations/surfaces'; -import { colorSchemes, typography, shadows, shape } from './themePrimitives'; +import * as React from "react"; +import { + ThemeProvider, + createTheme, + CssBaseline, + Box, +} from "@mui/material"; -interface AppThemeProps { +import { getDesignTokens } from "./themePrimitives"; +import { getSemanticColors } from "./themeConfig"; + +import { inputsCustomizations } from "./customizations/inputs"; +import { dataDisplayCustomizations } from "./customizations/dataDisplay"; +import { feedbackCustomizations } from "./customizations/feedback"; +import { navigationCustomizations } from "./customizations/navigation"; +import { surfacesCustomizations } from "./customizations/surfaces"; + +export type ColorMode = "light" | "dark"; + +type ColorModeContextValue = { + mode: ColorMode; + setMode: (mode: ColorMode) => void; + toggleColorMode: () => void; +}; + +export const ColorModeContext = + React.createContext({ + mode: "light", + setMode: () => {}, + toggleColorMode: () => {}, + }); + +type AppThemeProps = { children: React.ReactNode; - /** - * This is for the docs site. You can ignore it or remove it. - */ - disableCustomTheme?: boolean; - themeComponents?: ThemeOptions['components']; -} + defaultMode?: ColorMode; +}; + +export default function AppTheme({ + children, + defaultMode = "light", +}: AppThemeProps) { + const [mode, setMode] = + React.useState(defaultMode); + + const toggleColorMode = React.useCallback(() => { + setMode((prev) => + prev === "light" ? "dark" : "light" + ); + }, []); + + const contextValue = React.useMemo( + () => ({ + mode, + setMode, + toggleColorMode, + }), + [mode, toggleColorMode] + ); + + const semantic = React.useMemo( + () => getSemanticColors(mode), + [mode] + ); + + const theme = React.useMemo( + () => + createTheme({ + ...getDesignTokens(mode), + semantic, + + components: { + ...inputsCustomizations, + ...dataDisplayCustomizations, + ...feedbackCustomizations, + ...navigationCustomizations, + ...surfacesCustomizations, + }, + }), + [mode, semantic] + ); -export default function AppTheme(props: AppThemeProps) { - const { children, disableCustomTheme, themeComponents } = props; - const theme = React.useMemo(() => { - return disableCustomTheme - ? {} - : createTheme({ - // For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/ - cssVariables: { - colorSchemeSelector: 'data-mui-color-scheme', - cssVarPrefix: 'template', - }, - colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes - typography, - shadows, - shape, - components: { - ...inputsCustomizations, - ...dataDisplayCustomizations, - ...feedbackCustomizations, - ...navigationCustomizations, - ...surfacesCustomizations, - ...themeComponents, - }, - }); - }, [disableCustomTheme, themeComponents]); - if (disableCustomTheme) { - return {children}; - } return ( - - {children} - + + + + + {children} + + + ); } diff --git a/src/shared-theme/ColorModeIconDropdown.tsx b/src/shared-theme/ColorModeIconDropdown.tsx deleted file mode 100644 index 3af1e07..0000000 --- a/src/shared-theme/ColorModeIconDropdown.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from 'react'; -import DarkModeIcon from '@mui/icons-material/DarkModeRounded'; -import LightModeIcon from '@mui/icons-material/LightModeRounded'; -import Box from '@mui/material/Box'; -import IconButton, { IconButtonOwnProps } from '@mui/material/IconButton'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import { useColorScheme } from '@mui/material/styles'; - -export default function ColorModeIconDropdown(props: IconButtonOwnProps) { - const { mode, systemMode, setMode } = useColorScheme(); - const [anchorEl, setAnchorEl] = React.useState(null); - const open = Boolean(anchorEl); - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const handleClose = () => { - setAnchorEl(null); - }; - const handleMode = (targetMode: 'system' | 'light' | 'dark') => () => { - setMode(targetMode); - handleClose(); - }; - if (!mode) { - return ( - ({ - verticalAlign: 'bottom', - display: 'inline-flex', - width: '2.25rem', - height: '2.25rem', - borderRadius: (theme.vars || theme).shape.borderRadius, - border: '1px solid', - borderColor: (theme.vars || theme).palette.divider, - })} - /> - ); - } - const resolvedMode = (systemMode || mode) as 'light' | 'dark'; - const icon = { - light: , - dark: , - }[resolvedMode]; - return ( - - - {icon} - - - - System - - - Light - - - Dark - - - - ); -} diff --git a/src/shared-theme/ColorModeSelect.tsx b/src/shared-theme/ColorModeSelect.tsx deleted file mode 100644 index 6e71b9b..0000000 --- a/src/shared-theme/ColorModeSelect.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import { useColorScheme } from '@mui/material/styles'; -import MenuItem from '@mui/material/MenuItem'; -import Select, { SelectProps } from '@mui/material/Select'; - -export default function ColorModeSelect(props: SelectProps) { - const { mode, setMode } = useColorScheme(); - if (!mode) { - return null; - } - return ( - - ); -} diff --git a/src/shared-theme/customizations/feedback.tsx b/src/shared-theme/customizations/feedback.tsx index 6d475c9..5eb04db 100644 --- a/src/shared-theme/customizations/feedback.tsx +++ b/src/shared-theme/customizations/feedback.tsx @@ -14,8 +14,8 @@ export const feedbackCustomizations: Components = { color: orange[500], }, ...theme.applyStyles('dark', { - backgroundColor: `${alpha(orange[900], 0.5)}`, - border: `1px solid ${alpha(orange[800], 0.5)}`, + backgroundColor: alpha(orange[900], 0.35), + border: `1px solid ${alpha(orange[800], 0.3)}`, }), }), }, diff --git a/src/shared-theme/customizations/inputs.tsx b/src/shared-theme/customizations/inputs.tsx index b384563..e9d2ba5 100644 --- a/src/shared-theme/customizations/inputs.tsx +++ b/src/shared-theme/customizations/inputs.tsx @@ -125,15 +125,15 @@ export const inputsCustomizations: Components = { backgroundColor: gray[200], }, ...theme.applyStyles('dark', { - backgroundColor: gray[800], - borderColor: gray[700], + backgroundColor: 'hsla(0, 0%, 100%, 0.06)', + borderColor: (theme.vars || theme).palette.divider, '&:hover': { - backgroundColor: gray[900], - borderColor: gray[600], + backgroundColor: 'hsla(0, 0%, 100%, 0.1)', + borderColor: 'hsla(0, 0%, 100%, 0.15)', }, '&:active': { - backgroundColor: gray[900], + backgroundColor: 'hsla(0, 0%, 100%, 0.1)', }, }), }, @@ -183,12 +183,12 @@ export const inputsCustomizations: Components = { backgroundColor: gray[200], }, ...theme.applyStyles('dark', { - color: gray[50], + color: 'hsl(0, 0%, 92%)', '&:hover': { - backgroundColor: gray[700], + backgroundColor: 'hsla(0, 0%, 100%, 0.08)', }, '&:active': { - backgroundColor: alpha(gray[700], 0.7), + backgroundColor: 'hsla(0, 0%, 100%, 0.12)', }, }), }, @@ -241,14 +241,14 @@ export const inputsCustomizations: Components = { backgroundColor: gray[200], }, ...theme.applyStyles('dark', { - backgroundColor: gray[800], - borderColor: gray[700], + backgroundColor: 'hsla(0, 0%, 100%, 0.06)', + borderColor: (theme.vars || theme).palette.divider, '&:hover': { - backgroundColor: gray[900], - borderColor: gray[600], + backgroundColor: 'hsla(0, 0%, 100%, 0.1)', + borderColor: 'hsla(0, 0%, 100%, 0.15)', }, '&:active': { - backgroundColor: gray[900], + backgroundColor: 'hsla(0, 0%, 100%, 0.1)', }, }), variants: [ @@ -288,7 +288,7 @@ export const inputsCustomizations: Components = { [`& .${toggleButtonGroupClasses.selected}`]: { color: '#fff', }, - boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`, + boxShadow: `0 2px 8px ${alpha(brand[700], 0.3)}`, }), }), }, @@ -302,7 +302,7 @@ export const inputsCustomizations: Components = { fontWeight: 500, ...theme.applyStyles('dark', { color: gray[400], - boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.25)', [`&.${toggleButtonClasses.selected}`]: { color: brand[300], }, diff --git a/src/shared-theme/customizations/navigation.tsx b/src/shared-theme/customizations/navigation.tsx index 3cb9713..a82fc21 100644 --- a/src/shared-theme/customizations/navigation.tsx +++ b/src/shared-theme/customizations/navigation.tsx @@ -49,9 +49,8 @@ export const navigationCustomizations: Components = { }, }, ...theme.applyStyles('dark', { - background: gray[900], - boxShadow: - 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px', + background: (theme.vars || theme).palette.background.paper, + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)', }), }), }, @@ -84,17 +83,17 @@ export const navigationCustomizations: Components = { ...theme.applyStyles('dark', { borderRadius: (theme.vars || theme).shape.borderRadius, - borderColor: gray[700], + borderColor: (theme.vars || theme).palette.divider, backgroundColor: (theme.vars || theme).palette.background.paper, - boxShadow: `inset 0 1px 0 1px ${alpha(gray[700], 0.15)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`, + boxShadow: 'inset 0 1px 0 hsla(0, 0%, 100%, 0.05)', '&:hover': { - borderColor: alpha(gray[700], 0.7), + borderColor: 'hsla(0, 0%, 100%, 0.15)', backgroundColor: (theme.vars || theme).palette.background.paper, boxShadow: 'none', }, [`&.${selectClasses.focused}`]: { outlineOffset: 0, - borderColor: gray[900], + borderColor: 'hsl(210, 55%, 55%)', }, '&:before, &:after': { display: 'none', @@ -108,7 +107,7 @@ export const navigationCustomizations: Components = { display: 'flex', alignItems: 'center', '&:focus-visible': { - backgroundColor: gray[900], + backgroundColor: (theme.vars || theme).palette.background.default, }, }), }), @@ -151,6 +150,7 @@ export const navigationCustomizations: Components = { styleOverrides: { paper: ({ theme }) => ({ backgroundColor: (theme.vars || theme).palette.background.default, + borderRight: `1px solid ${(theme.vars || theme).palette.divider}`, }), }, }, @@ -204,8 +204,8 @@ export const navigationCustomizations: Components = { ...theme.applyStyles('dark', { ':hover': { color: (theme.vars || theme).palette.text.primary, - backgroundColor: gray[800], - borderColor: gray[700], + backgroundColor: alpha((theme.vars || theme).palette.common.white, 0.08), + borderColor: (theme.vars || theme).palette.divider, }, [`&.${tabClasses.selected}`]: { color: '#fff', diff --git a/src/shared-theme/customizations/surfaces.ts b/src/shared-theme/customizations/surfaces.ts index f47a6d8..7a2f318 100644 --- a/src/shared-theme/customizations/surfaces.ts +++ b/src/shared-theme/customizations/surfaces.ts @@ -40,7 +40,7 @@ export const surfacesCustomizations: Components = { '&:hover': { backgroundColor: gray[50] }, '&:focus-visible': { backgroundColor: 'transparent' }, ...theme.applyStyles('dark', { - '&:hover': { backgroundColor: gray[800] }, + '&:hover': { backgroundColor: alpha(theme.palette.common.white, 0.06) }, }), }), }, @@ -67,7 +67,7 @@ export const surfacesCustomizations: Components = { border: `1px solid ${(theme.vars || theme).palette.divider}`, boxShadow: 'none', ...theme.applyStyles('dark', { - backgroundColor: gray[800], + backgroundColor: (theme.vars || theme).palette.background.paper, }), variants: [ { @@ -79,7 +79,7 @@ export const surfacesCustomizations: Components = { boxShadow: 'none', background: 'hsl(0, 0%, 100%)', ...theme.applyStyles('dark', { - background: alpha(gray[900], 0.4), + background: alpha((theme.vars || theme).palette.background.paper, 0.6), }), }, }, diff --git a/src/shared-theme/themeConfig.ts b/src/shared-theme/themeConfig.ts new file mode 100644 index 0000000..9b82f95 --- /dev/null +++ b/src/shared-theme/themeConfig.ts @@ -0,0 +1,72 @@ +import { gray } from "./themePrimitives"; +import { alpha } from "@mui/material/styles"; + +declare module "@mui/material/styles" { + interface Theme { + semantic: SemanticColors; + } + interface ThemeOptions { + semantic?: SemanticColors; + } +} + +export type SemanticColorMode = "light" | "dark"; + +export interface SemanticColors { + surface: { + page: string; + card: string; + elevated: string; + }; + border: { + default: string; + subtle: string; + }; + text: { + primary: string; + secondary: string; + muted: string; + }; +} + +const darkBg = 'hsl(0, 0%, 9%)'; +const darkPaper = 'hsl(0, 0%, 14%)'; +const darkElevated = 'hsl(0, 0%, 19%)'; + +export function getSemanticColors(mode: SemanticColorMode): SemanticColors { + if (mode === "dark") { + return { + surface: { + page: darkBg, + card: darkPaper, + elevated: darkElevated, + }, + border: { + default: 'hsla(0, 0%, 100%, 0.08)', + subtle: 'hsla(0, 0%, 100%, 0.04)', + }, + text: { + primary: 'hsl(0, 0%, 92%)', + secondary: 'hsl(0, 0%, 60%)', + muted: 'hsl(0, 0%, 45%)', + }, + }; + } + + return { + surface: { + page: "hsl(0, 0%, 99%)", + card: "hsl(220, 35%, 97%)", + elevated: gray[100], + }, + border: { + default: alpha(gray[300], 0.4), + subtle: alpha(gray[200], 0.3), + }, + text: { + primary: gray[800], + secondary: gray[600], + muted: gray[500], + }, + }; +} diff --git a/src/shared-theme/themePrimitives.ts b/src/shared-theme/themePrimitives.ts index 97b2c3d..1e157f6 100644 --- a/src/shared-theme/themePrimitives.ts +++ b/src/shared-theme/themePrimitives.ts @@ -23,6 +23,10 @@ declare module '@mui/material/styles' { interface Palette { baseShadow: string; + flows: { + outflows: { primary: string; surface: string; text: string }; + inflows: { primary: string; surface: string; text: string }; + }; } } @@ -52,7 +56,9 @@ export const gray = { 500: 'hsl(220, 20%, 42%)', 600: 'hsl(220, 20%, 35%)', 700: 'hsl(220, 20%, 25%)', + 750: 'hsl(220, 20%, 18%)', 800: 'hsl(220, 30%, 6%)', + 850: 'hsl(220, 22%, 11%)', 900: 'hsl(220, 35%, 3%)', }; @@ -95,10 +101,14 @@ export const red = { 900: 'hsl(0, 93%, 6%)', }; +const darkBg = 'hsl(0, 0%, 9%)'; +const darkPaper = 'hsl(0, 0%, 14%)'; +const darkElevated = 'hsl(0, 0%, 19%)'; + export const getDesignTokens = (mode: PaletteMode) => { customShadows[1] = mode === 'dark' - ? 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px' + ? '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)' : 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px'; return { @@ -111,9 +121,9 @@ export const getDesignTokens = (mode: PaletteMode) => { contrastText: brand[50], ...(mode === 'dark' && { contrastText: brand[50], - light: brand[300], - main: brand[400], - dark: brand[700], + light: 'hsl(210, 50%, 65%)', + main: 'hsl(210, 55%, 55%)', + dark: 'hsl(210, 50%, 35%)', }), }, info: { @@ -122,10 +132,10 @@ export const getDesignTokens = (mode: PaletteMode) => { dark: brand[600], contrastText: gray[50], ...(mode === 'dark' && { - contrastText: brand[300], - light: brand[500], - main: brand[700], - dark: brand[900], + contrastText: 'hsl(210, 30%, 80%)', + light: 'hsl(210, 40%, 50%)', + main: 'hsl(210, 35%, 40%)', + dark: 'hsl(210, 30%, 25%)', }), }, warning: { @@ -133,9 +143,9 @@ export const getDesignTokens = (mode: PaletteMode) => { main: orange[400], dark: orange[800], ...(mode === 'dark' && { - light: orange[400], - main: orange[500], - dark: orange[700], + light: 'hsl(45, 60%, 55%)', + main: 'hsl(45, 55%, 45%)', + dark: 'hsl(45, 50%, 30%)', }), }, error: { @@ -143,9 +153,9 @@ export const getDesignTokens = (mode: PaletteMode) => { main: red[400], dark: red[800], ...(mode === 'dark' && { - light: red[400], - main: red[500], - dark: red[700], + light: 'hsl(0, 55%, 60%)', + main: 'hsl(0, 55%, 50%)', + dark: 'hsl(0, 50%, 35%)', }), }, success: { @@ -153,34 +163,46 @@ export const getDesignTokens = (mode: PaletteMode) => { main: green[400], dark: green[800], ...(mode === 'dark' && { - light: green[400], - main: green[500], - dark: green[700], + light: 'hsl(120, 40%, 55%)', + main: 'hsl(120, 40%, 45%)', + dark: 'hsl(120, 35%, 30%)', }), }, grey: { ...gray, }, - divider: mode === 'dark' ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4), + divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4), background: { default: 'hsl(0, 0%, 99%)', paper: 'hsl(220, 35%, 97%)', - ...(mode === 'dark' && { default: gray[900], paper: 'hsl(220, 30%, 7%)' }), + ...(mode === 'dark' && { default: darkBg, paper: darkPaper }), }, text: { primary: gray[800], secondary: gray[600], warning: orange[400], - ...(mode === 'dark' && { primary: 'hsl(0, 0%, 100%)', secondary: gray[400] }), + ...(mode === 'dark' && { primary: 'hsl(0, 0%, 92%)', secondary: 'hsl(0, 0%, 60%)' }), }, action: { hover: alpha(gray[200], 0.2), selected: `${alpha(gray[200], 0.3)}`, ...(mode === 'dark' && { - hover: alpha(gray[600], 0.2), - selected: alpha(gray[600], 0.3), + hover: 'hsla(0, 0%, 100%, 0.06)', + selected: 'hsla(0, 0%, 100%, 0.1)', }), }, + flows: { + outflows: { + primary: mode === 'dark' ? 'hsl(0, 55%, 60%)' : '#d32f2f', + surface: mode === 'dark' ? 'hsla(0, 35%, 25%, 0.6)' : '#fdecea', + text: mode === 'dark' ? 'hsl(0, 60%, 80%)' : '#b71c1c', + }, + inflows: { + primary: mode === 'dark' ? 'hsl(120, 40%, 55%)' : '#2e7d32', + surface: mode === 'dark' ? 'hsla(120, 25%, 22%, 0.6)' : '#e8f5e9', + text: mode === 'dark' ? 'hsl(120, 40%, 78%)' : '#1b5e20', + }, + }, }, typography: { fontFamily: 'Inter, sans-serif', @@ -285,6 +307,18 @@ export const colorSchemes = { hover: alpha(gray[200], 0.2), selected: `${alpha(gray[200], 0.3)}`, }, + flows: { + outflows: { + primary: '#d32f2f', + surface: '#fdecea', + text: '#b71c1c', + }, + inflows: { + primary: '#2e7d32', + surface: '#e8f5e9', + text: '#1b5e20', + }, + }, baseShadow: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px', }, @@ -293,49 +327,60 @@ export const colorSchemes = { palette: { primary: { contrastText: brand[50], - light: brand[300], - main: brand[400], - dark: brand[700], + light: 'hsl(210, 50%, 65%)', + main: 'hsl(210, 55%, 55%)', + dark: 'hsl(210, 50%, 35%)', }, info: { - contrastText: brand[300], - light: brand[500], - main: brand[700], - dark: brand[900], + contrastText: 'hsl(210, 30%, 80%)', + light: 'hsl(210, 40%, 50%)', + main: 'hsl(210, 35%, 40%)', + dark: 'hsl(210, 30%, 25%)', }, warning: { - light: orange[400], - main: orange[500], - dark: orange[700], + light: 'hsl(45, 60%, 55%)', + main: 'hsl(45, 55%, 45%)', + dark: 'hsl(45, 50%, 30%)', }, error: { - light: red[400], - main: red[500], - dark: red[700], + light: 'hsl(0, 55%, 60%)', + main: 'hsl(0, 55%, 50%)', + dark: 'hsl(0, 50%, 35%)', }, success: { - light: green[400], - main: green[500], - dark: green[700], + light: 'hsl(120, 40%, 55%)', + main: 'hsl(120, 40%, 45%)', + dark: 'hsl(120, 35%, 30%)', }, grey: { ...gray, }, - divider: alpha(gray[700], 0.6), + divider: 'hsla(0, 0%, 100%, 0.08)', background: { - default: gray[900], - paper: 'hsl(220, 30%, 7%)', + default: darkBg, + paper: darkPaper, }, text: { - primary: 'hsl(0, 0%, 100%)', - secondary: gray[400], + primary: 'hsl(0, 0%, 92%)', + secondary: 'hsl(0, 0%, 60%)', }, action: { - hover: alpha(gray[600], 0.2), - selected: alpha(gray[600], 0.3), + hover: 'hsla(0, 0%, 100%, 0.06)', + selected: 'hsla(0, 0%, 100%, 0.1)', }, - baseShadow: - 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px', + flows: { + outflows: { + primary: 'hsl(0, 55%, 60%)', + surface: 'hsla(0, 35%, 25%, 0.6)', + text: 'hsl(0, 60%, 80%)', + }, + inflows: { + primary: 'hsl(120, 40%, 55%)', + surface: 'hsla(120, 25%, 22%, 0.6)', + text: 'hsl(120, 40%, 78%)', + }, + }, + baseShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)', }, }, };