diff --git a/src/features/theme/services/MaterialYouThemeOptions.test.ts b/src/features/theme/services/MaterialYouThemeOptions.test.ts new file mode 100644 index 00000000..2176dd02 --- /dev/null +++ b/src/features/theme/services/MaterialYouThemeOptions.test.ts @@ -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/); + }); +}); diff --git a/src/features/theme/services/MaterialYouThemeOptions.ts b/src/features/theme/services/MaterialYouThemeOptions.ts new file mode 100644 index 00000000..0158463c --- /dev/null +++ b/src/features/theme/services/MaterialYouThemeOptions.ts @@ -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 }, + }, + }, + }, +}); diff --git a/src/features/theme/services/ThemeCreator.ts b/src/features/theme/services/ThemeCreator.ts index 716c89b5..ddae1c9c 100644 --- a/src/features/theme/services/ThemeCreator.ts +++ b/src/features/theme/services/ThemeCreator.ts @@ -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 & Partial) | 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, @@ -115,57 +34,45 @@ const getVibrantColorForTheme = ( return mode === ThemeMode.LIGHT ? palette.DarkVibrant.hex : palette.LightVibrant.hex; }; +const getThemeSeed = ( + appTheme: AppTheme['muiTheme'], + mode: Exclude, +): 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, ): 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 ?? ''} } `, },