feat(theme): apply Material 3 component system
This commit is contained in:
42
src/features/theme/services/MaterialYouThemeOptions.test.ts
Normal file
42
src/features/theme/services/MaterialYouThemeOptions.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createMaterialYouScheme } from '@/features/theme/services/MaterialYouPalette.ts';
|
||||
import {
|
||||
createMaterialYouThemeOptions,
|
||||
MATERIAL_YOU_MOTION,
|
||||
} from '@/features/theme/services/MaterialYouThemeOptions.ts';
|
||||
|
||||
describe('createMaterialYouThemeOptions', () => {
|
||||
const options = createMaterialYouThemeOptions(createMaterialYouScheme('#5B74EF', 'dark')) as any;
|
||||
|
||||
it('uses Material 3 geometry and readable button labels', () => {
|
||||
assert.equal(options.shape.borderRadius, 20);
|
||||
assert.equal(options.components.MuiCard.styleOverrides.root.borderRadius, 20);
|
||||
assert.equal(options.components.MuiDialog.styleOverrides.paper.borderRadius, 28);
|
||||
assert.equal(options.components.MuiButton.styleOverrides.root.borderRadius, 999);
|
||||
assert.equal(options.components.MuiButton.styleOverrides.root.textTransform, 'none');
|
||||
assert.equal(options.components.MuiChip.styleOverrides.root.borderRadius, 999);
|
||||
});
|
||||
|
||||
it('uses tonal surfaces instead of elevated app chrome', () => {
|
||||
assert.equal(options.components.MuiAppBar.defaultProps.elevation, 0);
|
||||
assert.equal(options.components.MuiDrawer.styleOverrides.paper.boxShadow, 'none');
|
||||
assert.equal(options.palette.background.default, '#11131A');
|
||||
assert.equal(options.palette.background.paper, '#191B23');
|
||||
});
|
||||
|
||||
it('provides focus and reduced-motion tokens', () => {
|
||||
assert.equal(options.focusRing, '0 0 0 3px rgba(183, 194, 255, 0.72)');
|
||||
assert.equal(MATERIAL_YOU_MOTION.standard, '180ms cubic-bezier(0.2, 0, 0, 1)');
|
||||
assert.match(options.components.MuiCssBaseline.styleOverrides, /prefers-reduced-motion/);
|
||||
assert.match(options.components.MuiCssBaseline.styleOverrides, /focus-visible/);
|
||||
});
|
||||
});
|
||||
151
src/features/theme/services/MaterialYouThemeOptions.ts
Normal file
151
src/features/theme/services/MaterialYouThemeOptions.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import type { MaterialYouScheme } from '@/features/theme/services/MaterialYouPalette.ts';
|
||||
|
||||
export const MATERIAL_YOU_MOTION = {
|
||||
standard: '180ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
emphasized: '320ms cubic-bezier(0.2, 0, 0, 1)',
|
||||
} as const;
|
||||
|
||||
const FOCUS_RING = '0 0 0 3px rgba(183, 194, 255, 0.72)';
|
||||
|
||||
export const createMaterialYouThemeOptions = (scheme: MaterialYouScheme) => ({
|
||||
focusRing: FOCUS_RING,
|
||||
shape: {
|
||||
borderRadius: 20,
|
||||
},
|
||||
palette: {
|
||||
primary: {
|
||||
main: scheme.primary,
|
||||
contrastText: scheme.onPrimary,
|
||||
},
|
||||
secondary: {
|
||||
main: scheme.secondary,
|
||||
contrastText: scheme.onSecondary,
|
||||
},
|
||||
background: {
|
||||
default: scheme.surface,
|
||||
paper: scheme.surfaceContainer,
|
||||
},
|
||||
text: {
|
||||
primary: scheme.onSurface,
|
||||
secondary: scheme.onSurfaceVariant,
|
||||
},
|
||||
divider: scheme.outlineVariant,
|
||||
},
|
||||
typography: {
|
||||
fontFamily:
|
||||
'Roboto, "Noto Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif',
|
||||
h1: { fontWeight: 500, letterSpacing: '-0.02em' },
|
||||
h2: { fontWeight: 500, letterSpacing: '-0.015em' },
|
||||
h3: { fontWeight: 500 },
|
||||
h4: { fontWeight: 500 },
|
||||
h5: { fontWeight: 500 },
|
||||
h6: { fontWeight: 600 },
|
||||
button: { fontWeight: 600, letterSpacing: '0.01em' },
|
||||
},
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: `
|
||||
:root { color-scheme: light dark; }
|
||||
body { background: ${scheme.surface}; }
|
||||
* { scrollbar-color: ${scheme.outlineVariant} transparent; }
|
||||
:where(a, button, input, textarea, select, [tabindex]):focus-visible {
|
||||
outline: none;
|
||||
box-shadow: ${FOCUS_RING};
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minHeight: 40,
|
||||
borderRadius: 999,
|
||||
paddingInline: 24,
|
||||
textTransform: 'none',
|
||||
transition: `background-color ${MATERIAL_YOU_MOTION.standard}, transform ${MATERIAL_YOU_MOTION.standard}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
transition: `background-color ${MATERIAL_YOU_MOTION.standard}, color ${MATERIAL_YOU_MOTION.standard}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
defaultProps: { elevation: 0 },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: { backgroundImage: 'none' },
|
||||
rounded: { borderRadius: 20 },
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: { borderRadius: 28, backgroundColor: scheme.surfaceContainerHigh },
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { minHeight: 32, borderRadius: 999 },
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 16 },
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
defaultProps: { elevation: 0, color: 'transparent' as const },
|
||||
styleOverrides: {
|
||||
root: { backgroundColor: scheme.surface, backgroundImage: 'none', color: scheme.onSurface },
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
border: 0,
|
||||
boxShadow: 'none',
|
||||
backgroundColor: scheme.surfaceContainer,
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: { borderRadius: 8, fontSize: '0.75rem' },
|
||||
},
|
||||
},
|
||||
MuiSkeleton: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 12, backgroundColor: scheme.surfaceVariant },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -6,104 +6,23 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import type { Direction, PaletteBackgroundChannel, Theme, TypeBackground } from '@mui/material/styles';
|
||||
import type { Direction, Theme } from '@mui/material/styles';
|
||||
import { createTheme as createMuiTheme, responsiveFontSizes, useTheme } from '@mui/material/styles';
|
||||
import { useCallback } from 'react';
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import { deepmerge } from '@mui/utils';
|
||||
import { complement, hsl, parseToHsl } from 'polished';
|
||||
import type { HslaColor, HslColor } from 'polished/lib/types/color';
|
||||
import type { AppTheme } from '@/features/theme/services/AppThemes.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
||||
import type { TAppThemeContext } from '@/features/theme/AppTheme.types.ts';
|
||||
import { ThemeMode } from '@/features/theme/AppTheme.types.ts';
|
||||
import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts';
|
||||
import { coerceIn } from '@/lib/HelperFunctions.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { createMaterialYouScheme, MATERIAL_YOU_STABLE_SEED } from '@/features/theme/services/MaterialYouPalette.ts';
|
||||
import { createMaterialYouThemeOptions } from '@/features/theme/services/MaterialYouThemeOptions.ts';
|
||||
|
||||
const SCROLLBAR_SIZE = 14;
|
||||
|
||||
const isNeutralColor = (color: HslColor | HslaColor): boolean => {
|
||||
const isDesaturated = color.saturation < 0.08;
|
||||
const isNearBlack = color.lightness < 0.03;
|
||||
const isNearWhite = color.lightness > 0.97;
|
||||
|
||||
return isDesaturated || isNearBlack || isNearWhite;
|
||||
};
|
||||
|
||||
const createBackgroundColors = (color: string, lightnessPaper: number, lightnessDefault: number): TypeBackground => {
|
||||
const colorHsl = parseToHsl(color);
|
||||
|
||||
const saturationBoost = isNeutralColor(colorHsl) ? 0 : 0.15;
|
||||
const saturation = coerceIn(colorHsl.saturation + saturationBoost, 0, 1);
|
||||
|
||||
return {
|
||||
paper: hsl(colorHsl.hue, saturation, lightnessPaper),
|
||||
default: hsl(colorHsl.hue, saturation, lightnessDefault),
|
||||
};
|
||||
};
|
||||
|
||||
const getBackgroundColor = (
|
||||
type: 'light' | 'dark',
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
theme: Theme,
|
||||
setPureBlackMode: boolean = false,
|
||||
): (Partial<TypeBackground> & Partial<PaletteBackgroundChannel>) | undefined => {
|
||||
if (setPureBlackMode) {
|
||||
return {
|
||||
paper: '#000',
|
||||
default: '#000',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'light' && !!theme.colorSchemes.light) {
|
||||
if (typeof appTheme.colorSchemes?.light === 'object' && appTheme.colorSchemes.light.palette?.background) {
|
||||
return appTheme.colorSchemes.light.palette.background;
|
||||
}
|
||||
|
||||
return createBackgroundColors(theme.colorSchemes.light.palette.primary.dark, 0.87, 0.93);
|
||||
}
|
||||
|
||||
if (type === 'dark' && !!theme.colorSchemes.dark) {
|
||||
if (typeof appTheme.colorSchemes?.dark === 'object' && appTheme.colorSchemes.dark.palette?.background) {
|
||||
return appTheme.colorSchemes.dark.palette.background;
|
||||
}
|
||||
|
||||
return createBackgroundColors(theme.colorSchemes.dark.palette.primary.dark, 0.06, 0.03);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const createAppThemeWithDynamicColors = (
|
||||
primaryColorDark: string | null | undefined,
|
||||
primaryColorLight: string | null | undefined,
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
): AppTheme['muiTheme'] => {
|
||||
if (!primaryColorDark || !primaryColorLight) {
|
||||
return appTheme;
|
||||
}
|
||||
|
||||
return {
|
||||
...appTheme,
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: { main: primaryColorLight },
|
||||
secondary: { main: complement(primaryColorLight) },
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: { main: primaryColorDark },
|
||||
secondary: { main: complement(primaryColorDark) },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies AppTheme['muiTheme'];
|
||||
};
|
||||
|
||||
const getVibrantColorForTheme = (
|
||||
palette: TAppThemeContext['dynamicColor'] | null,
|
||||
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
|
||||
@@ -115,57 +34,45 @@ const getVibrantColorForTheme = (
|
||||
return mode === ThemeMode.LIGHT ? palette.DarkVibrant.hex : palette.LightVibrant.hex;
|
||||
};
|
||||
|
||||
const getThemeSeed = (
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
|
||||
): string => {
|
||||
const colorScheme = appTheme.colorSchemes?.[mode];
|
||||
if (typeof colorScheme !== 'object') {
|
||||
return MATERIAL_YOU_STABLE_SEED;
|
||||
}
|
||||
|
||||
const primary = colorScheme.palette?.primary;
|
||||
return typeof primary === 'object' && primary !== null && 'main' in primary && typeof primary.main === 'string'
|
||||
? primary.main
|
||||
: MATERIAL_YOU_STABLE_SEED;
|
||||
};
|
||||
|
||||
export const createAppColorTheme = (
|
||||
appTheme: AppTheme['muiTheme'],
|
||||
dynamicColor: TAppThemeContext['dynamicColor'],
|
||||
setPureBlackMode: boolean,
|
||||
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
|
||||
): AppTheme['muiTheme'] => {
|
||||
const appThemeWithDominantPrimaryColor = createAppThemeWithDynamicColors(
|
||||
dynamicColor?.average.hex,
|
||||
dynamicColor?.average.hex,
|
||||
appTheme,
|
||||
const lightSeed = getVibrantColorForTheme(dynamicColor, ThemeMode.LIGHT) ?? getThemeSeed(appTheme, ThemeMode.LIGHT);
|
||||
const darkSeed = getVibrantColorForTheme(dynamicColor, ThemeMode.DARK) ?? getThemeSeed(appTheme, ThemeMode.DARK);
|
||||
const lightOptions = createMaterialYouThemeOptions(createMaterialYouScheme(lightSeed, ThemeMode.LIGHT));
|
||||
const darkOptions = createMaterialYouThemeOptions(
|
||||
createMaterialYouScheme(darkSeed, ThemeMode.DARK, setPureBlackMode),
|
||||
);
|
||||
const themePrimaryColorForBackground = createMuiTheme({
|
||||
...appThemeWithDominantPrimaryColor,
|
||||
defaultColorScheme: mode,
|
||||
});
|
||||
const activeOptions = mode === ThemeMode.DARK ? darkOptions : lightOptions;
|
||||
|
||||
const themeBackgroundColor = deepmerge(appThemeWithDominantPrimaryColor, {
|
||||
return deepmerge(appTheme, {
|
||||
defaultColorScheme: mode,
|
||||
shape: activeOptions.shape,
|
||||
typography: deepmerge(activeOptions.typography, appTheme.typography ?? {}),
|
||||
components: deepmerge(activeOptions.components, appTheme.components ?? {}),
|
||||
colorSchemes: {
|
||||
light: themePrimaryColorForBackground.colorSchemes?.light
|
||||
? {
|
||||
palette: {
|
||||
background: getBackgroundColor(
|
||||
'light',
|
||||
appThemeWithDominantPrimaryColor,
|
||||
themePrimaryColorForBackground,
|
||||
),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
dark: themePrimaryColorForBackground.colorSchemes?.dark
|
||||
? {
|
||||
palette: {
|
||||
background: getBackgroundColor(
|
||||
'dark',
|
||||
appThemeWithDominantPrimaryColor,
|
||||
themePrimaryColorForBackground,
|
||||
setPureBlackMode,
|
||||
),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
light: { palette: lightOptions.palette },
|
||||
dark: { palette: darkOptions.palette },
|
||||
},
|
||||
});
|
||||
|
||||
const appThemeWithVibrantPrimaryColor = createAppThemeWithDynamicColors(
|
||||
getVibrantColorForTheme(dynamicColor, ThemeMode.DARK),
|
||||
getVibrantColorForTheme(dynamicColor, ThemeMode.LIGHT),
|
||||
themeBackgroundColor,
|
||||
);
|
||||
return deepmerge(themeBackgroundColor, appThemeWithVibrantPrimaryColor);
|
||||
}) as AppTheme['muiTheme'];
|
||||
};
|
||||
|
||||
export const createTheme = (
|
||||
@@ -195,26 +102,26 @@ export const createTheme = (
|
||||
direction,
|
||||
typography: {
|
||||
fontSize: 13,
|
||||
...appTheme.muiTheme.typography,
|
||||
...appColorTheme.typography,
|
||||
},
|
||||
components: {
|
||||
...appTheme.muiTheme.components,
|
||||
...appColorTheme.components,
|
||||
MuiUseMediaQuery: {
|
||||
defaultProps: {
|
||||
noSsr: true,
|
||||
},
|
||||
},
|
||||
MuiCssBaseline: {
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline,
|
||||
...appColorTheme.components?.MuiCssBaseline,
|
||||
styleOverrides:
|
||||
typeof appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides === 'object'
|
||||
typeof appColorTheme.components?.MuiCssBaseline?.styleOverrides === 'object'
|
||||
? {
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides,
|
||||
...appColorTheme.components?.MuiCssBaseline?.styleOverrides,
|
||||
'*::-webkit-scrollbar': applyStyles(doesDeviceSupportHover, {
|
||||
width: `${SCROLLBAR_SIZE}px`,
|
||||
height: `${SCROLLBAR_SIZE}px`,
|
||||
// @ts-ignore - '*::-webkit-scrollbar' is a valid key
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
|
||||
...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
|
||||
'*::-webkit-scrollbar'
|
||||
],
|
||||
}),
|
||||
@@ -224,14 +131,14 @@ export const createTheme = (
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: `${themeForColors.palette.primary[isDarkMode ? 'dark' : 'light']}`,
|
||||
// @ts-ignore - '*::-webkit-scrollbar-thumb' is a valid key
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
|
||||
...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
|
||||
'*::-webkit-scrollbar-thumb'
|
||||
],
|
||||
}),
|
||||
'*::-webkit-scrollbar-thumb:hover': applyStyles(doesDeviceSupportHover, {
|
||||
borderWidth: '2px',
|
||||
// @ts-ignore - '*::-webkit-scrollbar-thumb:hover' is a valid key
|
||||
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
|
||||
...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
|
||||
'*::-webkit-scrollbar-thumb:hover'
|
||||
],
|
||||
}),
|
||||
@@ -252,7 +159,7 @@ export const createTheme = (
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
${appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides ?? ''}
|
||||
${appColorTheme.components?.MuiCssBaseline?.styleOverrides ?? ''}
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user