// TODO: /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import type { FixedColors, PaletteOptions, Shadow, ThemeColorsOptions, } from "@mui/material"; import { createTheme } from "@mui/material"; import type { Components } from "@mui/material/styles/components"; import type { TypographyOptions } from "@mui/material/styles/createTypography"; export enum THEME_COLOR { LIGHT = "light", DARK = "dark", } export const getTheme = ( themeColor: THEME_COLOR, colorAccentType: ColorAccentType, ) => { const colors = getColors(themeColor, colorAccentType); const palette = getPallette(themeColor, colors); const components = getComponents(colors, typography); return createTheme({ colors, palette, typography, components, shape: { // Increase the default border radius mulitplier from 4 to 8. borderRadius: 8, }, transitions: { // Increase the default transition out duration from 195 to 300. duration: { leavingScreen: 300 }, }, }); }; export type ColorAccentType = "auth" | "photos"; /** * [Note: Colors] * * The word "color" in MUI stands for different things. In particular, the color * prop for (e.g.) a Button is not the same as the color prop for the sx prop. * * There are three layers (only the first is necessary, rest are semantic): * * 1. Consider some color, say a shade of red. This will be represented by its * exact CSS color value, say "#ee0000". * * 2. These can be groups of color values that have roughly the same hue, but * different levels of saturation. Such hue groups are arranged together into * a "Colors" exported by "@/mui/material": * * export interface Color { * 50: string; * 100: string; * ... * 800: string; * 900: string; * A100: string; * ... * A700: string; * } * * 3. Finally, there are "PaletteColors" (the naming of props and documentation * within MUI (as of v6) omits the palette qualifier). * * export interface PaletteColor { * light: string; * main: string; * dark: string; * contrastText: string; * } * * The PaletteColors are what we use in the MUI component props (e.g. Button). * The PaletteColors are defined by providing color values for the four "tokens" * that make them up, either directly ("#aa000") or via Colors ("red[500]"). * * Within the sx prop we need to specify a color value, which can come from the * palette. The "palette", as defined by the palette property we provide when * creating the theme, can consist of arbitrary and nested key value pairs. * Within sx prop, the "color" and "backgroundColor" props can refer to paths * inside this palette object. That is, * * sx={{ color: "foo.bar" }} * * resolves to theme.vars.palette.foo.bar. * * [Note: Color names] * * When defining color names, there is some attempt at trying to use MUI v6 * (which uses MD (Material Design) v2) nomenclature when feasible (just to keep * the number of concepts low), but as such, the color names should not be * thought of as following Material Design, and should be treated as arbitrary * tokens reflecting our own design system. * * Some callouts: * * - Our "primary" and "secondary" are neutral grays instead of the two-tone * primary and secondary in MD. Our "accent" is corresponds to the MD primary. * * - Our "critical" is similar to the MD error. The other semantic PaletteColors * exported from MUI (error, warning, info, success) are not used. * * [Note: Theme and palette custom variables] * * Custom variables can be added to both the top level theme object, and to the * palette object within the theme. One would imagine that within the palette * there would only be colors that follow PaletteColor, but that's not something * MUI itself follows. * * So there is no particular reason to place custom color related variables in * theme vs in the palette. As a convention: * * - Custom colors (even if they're not PaletteColors) go within the palette. * * - All other custom variables remain within the top level theme. */ const getColors = ( themeColor: THEME_COLOR, accentType: ColorAccentType, ): ThemeColorsOptions => { switch (themeColor) { case THEME_COLOR.LIGHT: return { ...fixedColors(accentType), ...lightThemeColors }; default: return { ...fixedColors(accentType), ...darkThemeColors }; } }; const fixedColors = ( accentType: "auth" | "photos", ): Pick => { switch (accentType) { case "auth": return { ...commonFixedColors, accent: authAccentColor, }; default: return { ...commonFixedColors, accent: photosAccentColor, }; } }; const commonFixedColors: Partial> = { accent: { A700: "#00B33C", A500: "#1DB954", A400: "#26CB5F", A300: "#01DE4D", }, warning: { A500: "#FFC247", }, danger: { A800: "#F53434", A700: "#EA3F3F", A500: "#FF6565", A400: "#FF6F6F", }, white: { base: "#fff", muted: "rgba(255, 255, 255, 0.48)" }, black: { base: "#000", muted: "rgba(0, 0, 0, 0.65)" }, }; const authAccentColor = { A700: "rgb(164, 0, 182)", A500: "rgb(150, 13, 214)", A400: "rgb(122, 41, 193)", A300: "rgb(152, 77, 244)", }; const photosAccentColor = { A700: "#00B33C", A500: "#1DB954", A400: "#26CB5F", A300: "#01DE4D", }; const lightThemeColors: Omit = { background: { base: "#fff", elevated: "#fff", elevated2: "rgba(153, 153, 153, 0.04)", }, backdrop: { base: "rgba(255, 255, 255, 0.92)", muted: "rgba(255, 255, 255, 0.75)", faint: "rgba(255, 255, 255, 0.30)", }, text: { base: "#000", muted: "rgba(0, 0, 0, 0.60)", faint: "rgba(0, 0, 0, 0.50)", }, fill: { base: "#000", muted: "rgba(0, 0, 0, 0.12)", faint: "rgba(0, 0, 0, 0.04)", basePressed: "rgba(0, 0, 0, 0.87))", faintPressed: "rgba(0, 0, 0, 0.08)", }, stroke: { base: "#000", muted: "rgba(0, 0, 0, 0.24)", faint: "rgba(0, 0, 0, 0.12)", }, /* // Light shadows2L: { float: "0px 0px 10px rgba(0, 0, 0, 0.25)", menu: "0px 0px 6px rgba(0, 0, 0, 0.16), 0px 3px 6px rgba(0, 0, 0, 0.12)", button: "0px 4px 4px rgba(0, 0, 0, 0.25)", }, // Dark shadows2D: { float: "0px 2px 12px rgba(0, 0, 0, 0.75)", menu: "0px 0px 6px rgba(0, 0, 0, 0.50), 0px 3px 6px rgba(0, 0, 0, 0.25)", button: "0px 4px 4px rgba(0, 0, 0, 0.75)", }, */ shadows: { float: [{ x: 0, y: 0, blur: 10, color: "rgba(0, 0, 0, 0.25)" }], menu: [ { x: 0, y: 0, blur: 6, color: "rgba(0, 0, 0, 0.16)", }, { x: 0, y: 3, blur: 6, color: "rgba(0, 0, 0, 0.12)", }, ], button: [ { x: 0, y: 4, blur: 4, color: "rgba(0, 0, 0, 0.25)", }, ], }, }; const darkThemeColors: Omit = { background: { base: "#000000", elevated: "#1b1b1b", elevated2: "#252525", }, backdrop: { base: "rgba(0, 0, 0, 0.90)", muted: "rgba(0, 0, 0, 0.65)", faint: "rgba(0, 0, 0,0.20)", }, text: { base: "#fff", muted: "rgba(255, 255, 255, 0.70)", faint: "rgba(255, 255, 255, 0.50)", }, fill: { base: "#fff", muted: "rgba(255, 255, 255, 0.16)", faint: "rgba(255, 255, 255, 0.12)", basePressed: "rgba(255, 255, 255, 0.90)", faintPressed: "rgba(255, 255, 255, 0.06)", }, stroke: { base: "#ffffff", muted: "rgba(255,255,255,0.24)", faint: "rgba(255,255,255,0.16)", }, shadows: { float: [ { x: 0, y: 2, blur: 12, color: "rgba(0, 0, 0, 0.75)", }, ], menu: [ { x: 0, y: 0, blur: 6, color: "rgba(0, 0, 0, 0.50)", }, { x: 0, y: 3, blur: 6, color: "rgba(0, 0, 0, 0.25)", }, ], button: [ { x: 0, y: 4, blur: 4, color: "rgba(0, 0, 0, 0.75)", }, ], }, }; const getPallette = ( themeColor: THEME_COLOR, colors: ThemeColorsOptions, ): PaletteOptions => { const paletteOptions = getPalletteOptions(themeColor, colors); switch (themeColor) { case THEME_COLOR.LIGHT: return { mode: "light", ...paletteOptions }; default: return { mode: "dark", ...paletteOptions }; } }; const getPalletteOptions = ( themeColor: THEME_COLOR, colors: ThemeColorsOptions, ): PaletteOptions => { return { primary: { // See: [Note: strict mode migration] // // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore main: colors.fill.base, dark: colors.fill?.basePressed, contrastText: themeColor === "dark" ? colors.black?.base : colors.white?.base, }, secondary: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore main: colors.fill.faint, dark: colors.fill?.faintPressed, contrastText: colors.text?.base, }, accent: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore main: colors.accent.A500, dark: colors.accent?.A700, contrastText: colors.white?.base, }, critical: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore main: colors.danger.A700, dark: colors.danger?.A800, contrastText: colors.white?.base, }, background: { default: colors.background?.base, paper: colors.background?.elevated, }, text: { primary: colors.text?.base, secondary: colors.text?.muted, disabled: colors.text?.faint, base: colors.text?.base, muted: colors.text?.muted, faint: colors.text?.faint, }, divider: colors.stroke?.faint, }; }; const typography: TypographyOptions = { // [Note: Font weights] // // We only use three font weights: // // - 500 (sx "regular", CSS "normal") // - 600 (sx "medium") // - 700 (sx "bold", CSS "bold") // // While the sx prop allows us to use keywords "regular", "medium" and // "bold", which we do elsewhere in the code, within this file those // keywords cannot be used in all contexts because they instead map to the // CSS keywords (which MUI can't and doesn't remap). To avoid any confusion, // within this file we only use the numeric values. // // --- // // MUI (as of v6) uses the following font weights by default: // - fontWeightLight 300 // - fontWeightRegular 400 // - fontWeightMedium 500 // - fontWeightBold 700 // // The browser default (CSS keyword "normal"), is also 400. // // However for Inter, the font that we use, 400 is too light, and to improve // legibility we change fontWeightRegular to 500. // // Correspondingly, we shift fontWeightMedium to 600. fontWeightBold then // ends up mapping back to 700, which also nicely coincides with the CSS // keyword "bold". // // MUI uses fontWeightLight only as the default font weight for the h1 and // h2 variants, but we override their font weight in our theme. Thus we // don't need to bother with the light variant (though for consistency of // specifying every value, we alias it the same weight as regular, 500). fontFamily: "Inter Variable, sans-serif", fontWeightLight: 500, fontWeightRegular: 500 /* CSS baseline reset sets this as the default */, fontWeightMedium: 600, fontWeightBold: 700, h1: { fontSize: "48px", lineHeight: "58px", fontWeight: 600 /* medium */, }, h2: { fontSize: "32px", lineHeight: "39px", fontWeight: 500 /* Reset to regular to override MUI's default theme */, }, h3: { fontSize: "24px", lineHeight: "29px", fontWeight: 600 /* medium */, }, h4: { fontSize: "22px", lineHeight: "27px", fontWeight: 500 /* Reset to regular to override MUI's default theme */, }, h5: { fontSize: "20px", lineHeight: "25px", fontWeight: 600 /* medium */, }, // h6 is the default variant used by MUI's DialogTitle. h6: { // The font size and line height below is the same as large. fontSize: "18px", lineHeight: "22px", fontWeight: 600 /* medium */, }, body: { fontSize: "16px", lineHeight: "20px", }, small: { fontSize: "14px", lineHeight: "17px", }, mini: { fontSize: "12px", lineHeight: "15px", }, tiny: { fontSize: "10px", lineHeight: "12px", }, }; const getComponents = ( colors: ThemeColorsOptions, typography: TypographyOptions, ): Components => ({ MuiCssBaseline: { styleOverrides: { body: { // MUI has different letter spacing for each variant, but those // are values arrived at for the default Material font, and // don't work for the font that we're using. // // So we reset the letter spacing for _all_ variants to a // reasonable value that works for our font. letterSpacing: "-0.011em", }, }, }, MuiTypography: { defaultProps: { // MUI has body1 as the default variant for Typography, but our // variant scheme is different, instead of body1/2, we have // large/body/small etc. So reset the default to our equivalent of // body1, which is "body". variant: "body", // Map all our custom variants to

. variantMapping: { body: "p", small: "p", mini: "p", tiny: "p", }, }, }, MuiDrawer: { styleOverrides: { root: { ".MuiBackdrop-root": { backgroundColor: colors.backdrop?.faint, }, }, }, }, MuiDialog: { defaultProps: { // This is required to prevent console errors about aria-hiding a // focused button when the dialog is closed. // // https://github.com/mui/material-ui/issues/43106#issuecomment-2314809028 closeAfterTransition: false, }, styleOverrides: { root: { ".MuiBackdrop-root": { backgroundColor: colors.backdrop?.faint, }, "& .MuiDialog-paper": { filter: getDropShadowStyle(colors.shadows?.float), }, // Reset the MUI default paddings to 16px everywhere. // // This is not a great choice either, usually most dialogs, for // one reason or the other, will need to customize this padding // anyway. But not resetting it to 16px leaves it at the MUI // defaults, which just doesn't work well with our designs. "& .MuiDialogTitle-root": { // MUI default is '16px 24px'. padding: "16px", }, "& .MuiDialogContent-root": { // MUI default is '20px 24px'. padding: "16px", // If the contents of the dialog's contents exceed the // available height, show a scrollbar just for the contents // instead of the entire dialog. overflowY: "auto", }, "& .MuiDialogActions-root": { // MUI default is way off for us since they cluster the // buttons to the right, while our designs usually want the // buttons to align with the heading / content. padding: "16px", }, ".MuiDialogTitle-root + .MuiDialogContent-root": { // MUI resets this to 0 when the content doesn't use // dividers (none of ours do). I feel that is a better // default, since unlike margins, padding doesn't collapse, // but changing this now would break existing layouts. paddingTop: "16px", }, }, }, }, MuiPaper: { // MUI applies a semi-transparent background image for elevation in dark // mode. Remove it to use the elevated Paper background from our design. styleOverrides: { root: { backgroundImage: "none" } }, }, MuiLink: { defaultProps: { color: colors.accent?.A500, underline: "none", }, styleOverrides: { root: { "&:hover": { underline: "always", color: colors.accent?.A500, }, }, }, }, MuiButton: { defaultProps: { // Change the default button variant from "text" to "contained". variant: "contained", }, styleOverrides: { // We don't use the size prop for the MUI button, or rather it // cannot be used, since we have fixed the paddings and font sizes // unconditionally here (which is all that the size prop changes). root: { padding: "12px 16px", borderRadius: "4px", textTransform: "none", // Body, but medium. fontSize: typography.body?.fontSize, lineHeight: typography.body?.lineHeight, fontWeight: 600, }, startIcon: { marginRight: "12px", "&& >svg": { fontSize: "20px", }, }, endIcon: { marginLeft: "12px", "&& >svg": { fontSize: "20px", }, }, }, }, MuiInputBase: { styleOverrides: { formControl: { // Give a symmetric border to the input field, by default the // border radius is only applied to the top for the "filled" // variant of input used inside TextFields. borderRadius: "8px", // TODO: Should we also add overflow hidden so that there is no // gap between the filled area and the (full width) border. Not // sure how this might interact with selects. // overflow: "hidden", // Hide the bottom border that always appears for the "filled" // variant of input used inside TextFields. "::before": { borderBottom: "none !important", }, }, }, }, MuiFilledInput: { styleOverrides: { input: { "&:autofill": { boxShadow: "#c7fd4f", }, }, }, }, MuiTextField: { defaultProps: { // The MUI default variant is "outlined", override it to use the // "filled" one by default. variant: "filled", // Reduce the vertical margins that MUI adds to the TextField. // // Note that this causes things to be too tight when the helper text // is shown, so this is not recommended for new code that we write. margin: "dense", }, styleOverrides: { root: { "& .MuiInputAdornment-root": { marginRight: "8px", }, }, }, }, MuiSvgIcon: { styleOverrides: { root: ({ ownerState }) => ({ ...getIconColor(ownerState, colors), }), }, }, MuiIconButton: { styleOverrides: { root: ({ ownerState }) => ({ ...getIconColor(ownerState, colors), padding: "12px", }), }, }, MuiSnackbar: { styleOverrides: { root: { // Set a default border radius for all snackbar's (e.g. // notification popups). borderRadius: "8px", }, }, }, MuiModal: { styleOverrides: { root: { '&:has(> div[style*="opacity: 0"])': { pointerEvents: "none", }, }, }, }, MuiMenuItem: { styleOverrides: { // don't reduce opacity of disabled items root: { "&.Mui-disabled": { opacity: 1, }, }, }, }, }); const getDropShadowStyle = (shadows: Shadow[] | undefined) => { return (shadows ?? []) .map( (shadow) => `drop-shadow(${shadow.x}px ${shadow.y}px ${shadow.blur}px ${shadow.color})`, ) .join(" "); }; interface IconColorableOwnerState { color?: string; disabled?: boolean; } function getIconColor( ownerState: IconColorableOwnerState, colors: ThemeColorsOptions, ) { switch (ownerState.color) { case "primary": return { color: colors.stroke?.base, }; case "secondary": return { color: colors.stroke?.muted, }; } if (ownerState.disabled) { return { color: colors.stroke?.faint, }; } return {}; }