feat(theme): apply Material 3 component system

This commit is contained in:
2026-07-23 01:00:51 +08:00
parent f3893d98d4
commit b62c7e6f31
3 changed files with 233 additions and 133 deletions

View 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/);
});
});

View 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 },
},
},
},
});

View File

@@ -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 ?? ''}
}
`,
},