import { createTheme, type Theme, type PaletteColor, type PaletteColorChannel, } from "@mui/material/styles"; const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif"; const FONT_HEADING = "'Urbanist', sans-serif"; const FONT_MONO = "'DM Mono', Menlo, monospace"; // Standard Material easing (matches the legacy --transition cubic-bezier). const EASE = "cubic-bezier(0.4, 0, 0.2, 1)"; // Dark-mode fills for FILLED colored surfaces (contained buttons, filled chips, // StatCard badges). The palette .main stays BRIGHT in dark mode (it drives text, // outlined buttons and row tints), but a bright fill makes WHITE text/glyphs // illegible — so filled surfaces drop to these darker shades (≈ the light-scheme // values) in dark mode. One rule everywhere: filled colored = white text, // readable in both themes. Light mode needs no override (its .main is dark). export const FILLED_DARK_BG = { error: { bg: "#dc2626", hover: "#b91c1c" }, success: { bg: "#15803d", hover: "#166534" }, warning: { bg: "#b45309", hover: "#92400e" }, info: { bg: "#1d4ed8", hover: "#1e40af" }, } as const; export const theme = createTheme({ cssVariables: { colorSchemeSelector: "[data-theme='%s']", }, colorSchemes: { light: { palette: { mode: "light", primary: { main: "#c73030" }, success: { main: "#15803d", contrastText: "#fff" }, warning: { main: "#b45309", contrastText: "#fff" }, error: { main: "#b91c1c", contrastText: "#fff" }, info: { main: "#1d4ed8", contrastText: "#fff" }, // Sidebar section hues (full objects so cssVariables emits *Channel // tokens — the nav tiles use channel-alpha washes). teal: { main: "#0e8a7c", light: "#3aa99c", dark: "#0a675d", contrastText: "#fff", }, violet: { main: "#6a4cb4", light: "#8a6fd0", dark: "#54399a", contrastText: "#fff", }, slate: { main: "#5c6470", light: "#7d8694", dark: "#454c56", contrastText: "#fff", }, background: { default: "#f4f3f1", paper: "#ffffff" }, text: { primary: "#1a1a1a", secondary: "#555555" }, divider: "rgba(0,0,0,0.1)", }, }, dark: { palette: { mode: "dark", primary: { main: "#d63031" }, success: { main: "#22c55e", contrastText: "#1a1a1a" }, warning: { main: "#f59e0b", contrastText: "#1a1a1a" }, error: { main: "#ef4444", contrastText: "#1a1a1a" }, info: { main: "#3b82f6", contrastText: "#1a1a1a" }, // Sidebar section hues — brighter mains so the glyphs read on dark. teal: { main: "#2dd4bf", light: "#5eead4", dark: "#14b8a6", contrastText: "#1a1a1a", }, violet: { main: "#a78bfa", light: "#c4b5fd", dark: "#8b5cf6", contrastText: "#1a1a1a", }, slate: { main: "#94a3b8", light: "#cbd5e1", dark: "#64748b", contrastText: "#1a1a1a", }, background: { default: "#0f0f0f", paper: "#1a1a1a" }, text: { primary: "#ffffff", secondary: "#a0a0a0" }, divider: "rgba(255,255,255,0.08)", }, }, }, shape: { borderRadius: 10 }, typography: { fontFamily: FONT_BODY, h1: { fontFamily: FONT_HEADING, fontWeight: 800 }, h2: { fontFamily: FONT_HEADING, fontWeight: 800 }, h3: { fontFamily: FONT_HEADING, fontWeight: 800 }, h4: { fontFamily: FONT_HEADING, fontWeight: 700, // Page/detail headlines: MUI's default 2.125rem overflows phone // viewports (long document titles + number). Scale down on xs only. "@media (max-width:600px)": { fontSize: "1.5rem" }, }, h5: { fontFamily: FONT_HEADING, fontWeight: 700 }, h6: { fontFamily: FONT_HEADING, fontWeight: 700 }, button: { textTransform: "none", fontWeight: 600 }, }, components: { MuiButton: { defaultProps: { disableElevation: true }, styleOverrides: { root: { borderRadius: 999, paddingInline: "0.95rem", // Snappy press (150ms) + smooth color/shadow (250ms). transition: [ `background-color 250ms ${EASE}`, `border-color 250ms ${EASE}`, `color 200ms ${EASE}`, `box-shadow 250ms ${EASE}`, `transform 150ms ${EASE}`, `filter 200ms ${EASE}`, ].join(", "), "&:active": { transform: "scale(0.97)" }, "@media (prefers-reduced-motion: reduce)": { transition: "none" }, }, containedPrimary: { backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)", boxShadow: "0 5px 14px rgba(214,48,49,0.32)", // The gradient can't transition, so animate lift + glow instead. "&:hover": { transform: "translateY(-1px)", filter: "brightness(1.04)", boxShadow: "0 8px 20px rgba(214,48,49,0.42)", }, "&:active": { transform: "translateY(0) scale(0.97)" }, // Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop // the lift/press transform (glow + brightness stay, they don't move). "@media (prefers-reduced-motion: reduce)": { "&:hover": { transform: "none" }, "&:active": { transform: "none" }, }, }, // Filled colored buttons: WHITE text in BOTH themes (was per-scheme // contrastText → near-black text on colored fills in dark mode, which // also clashed with the always-white primary — "black text on one red // button, white on another"). In dark mode the fill drops to a darker // shade so white stays legible. containedError: ({ theme }) => ({ color: "#fff", ...theme.applyStyles("dark", { "&:not(.Mui-disabled)": { backgroundColor: FILLED_DARK_BG.error.bg, "&:hover": { backgroundColor: FILLED_DARK_BG.error.hover }, }, }), }), containedSuccess: ({ theme }) => ({ color: "#fff", ...theme.applyStyles("dark", { "&:not(.Mui-disabled)": { backgroundColor: FILLED_DARK_BG.success.bg, "&:hover": { backgroundColor: FILLED_DARK_BG.success.hover }, }, }), }), containedWarning: ({ theme }) => ({ color: "#fff", ...theme.applyStyles("dark", { "&:not(.Mui-disabled)": { backgroundColor: FILLED_DARK_BG.warning.bg, "&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover }, }, }), }), containedInfo: ({ theme }) => ({ color: "#fff", ...theme.applyStyles("dark", { "&:not(.Mui-disabled)": { backgroundColor: FILLED_DARK_BG.info.bg, "&:hover": { backgroundColor: FILLED_DARK_BG.info.hover }, }, }), }), }, }, MuiCard: { styleOverrides: { root: { borderRadius: 16, boxShadow: "0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)", transition: `box-shadow 250ms ${EASE}, transform 250ms ${EASE}`, }, }, }, MuiChip: { styleOverrides: { root: { borderRadius: 999, fontWeight: 700, transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`, }, // Filled colored chips follow the same rule as filled buttons: white // label in both themes, darker fill in dark mode so white stays legible. // (Chip has no per-color `filledX` key, so target the color class and // scope to the filled variant.) colorError: ({ theme }) => ({ "&.MuiChip-filled": { color: "#fff", ...theme.applyStyles("dark", { backgroundColor: FILLED_DARK_BG.error.bg, "&.MuiChip-clickable:hover": { backgroundColor: FILLED_DARK_BG.error.hover, }, }), }, }), colorSuccess: ({ theme }) => ({ "&.MuiChip-filled": { color: "#fff", ...theme.applyStyles("dark", { backgroundColor: FILLED_DARK_BG.success.bg, "&.MuiChip-clickable:hover": { backgroundColor: FILLED_DARK_BG.success.hover, }, }), }, }), colorWarning: ({ theme }) => ({ "&.MuiChip-filled": { color: "#fff", ...theme.applyStyles("dark", { backgroundColor: FILLED_DARK_BG.warning.bg, "&.MuiChip-clickable:hover": { backgroundColor: FILLED_DARK_BG.warning.hover, }, }), }, }), colorInfo: ({ theme }) => ({ "&.MuiChip-filled": { color: "#fff", ...theme.applyStyles("dark", { backgroundColor: FILLED_DARK_BG.info.bg, "&.MuiChip-clickable:hover": { backgroundColor: FILLED_DARK_BG.info.hover, }, }), }, }), }, }, MuiOutlinedInput: { styleOverrides: { root: { // Soft brand focus ring fades in over 200ms. transition: `box-shadow 200ms ${EASE}`, "&.Mui-focused": { boxShadow: "0 0 0 3px rgba(199, 48, 48, 0.12)", }, "@media (prefers-reduced-motion: reduce)": { transition: "none" }, }, notchedOutline: { transition: `border-color 200ms ${EASE}`, }, }, }, }, }); export const fonts = { body: FONT_BODY, heading: FONT_HEADING, mono: FONT_MONO, }; // Custom sidebar-section palette entries (teal/violet/slate above). The // channel intersection matters: theme.vars.palette..mainChannel is how // the nav tiles build their alpha washes. type NavPaletteColor = PaletteColor & PaletteColorChannel; declare module "@mui/material/styles" { interface Palette { teal: NavPaletteColor; violet: NavPaletteColor; slate: NavPaletteColor; } interface PaletteOptions { teal?: PaletteOptions["primary"]; violet?: PaletteOptions["primary"]; slate?: PaletteOptions["primary"]; } } export type IconBadgeColor = | "default" | "primary" | "success" | "warning" | "error" | "info"; /** * sx fragment for a solid icon-badge tile: a `.main` fill with a WHITE * glyph, darkened in dark mode (FILLED_DARK_BG) so white stays legible on the * otherwise-bright success/warning/info/error fills. `default` = neutral grey. * Single source of truth for every labelled icon badge — use in an sx array * next to the tile's size/shape so they can never drift apart: * sx={[{ width: 40, height: 40, borderRadius: 2, display: "flex", … }, iconBadgeSx(color)]} */ export function iconBadgeSx(color: IconBadgeColor) { const bg = color === "default" ? "grey.600" : `${color}.main`; const darkBg = (FILLED_DARK_BG as Record)[color]?.bg; return (theme: Theme) => ({ bgcolor: bg, color: "common.white", ...(darkBg ? theme.applyStyles("dark", { backgroundColor: darkBg }) : {}), }); }