feat(ui): restyle core views with Material You

This commit is contained in:
2026-07-23 01:07:18 +08:00
parent 7a9520be9e
commit a0fd55eb37
10 changed files with 130 additions and 31 deletions

View File

@@ -23,6 +23,7 @@ import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.t
import { MANGA_COVER_ASPECT_RATIO } from '@/features/manga/Manga.constants.ts'; import { MANGA_COVER_ASPECT_RATIO } from '@/features/manga/Manga.constants.ts';
import { GridLayout } from '@/base/Base.types.ts'; import { GridLayout } from '@/base/Base.types.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx'; import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const BottomGradient = styled('div')({ const BottomGradient = styled('div')({
position: 'absolute', position: 'absolute',
@@ -78,10 +79,15 @@ export const MangaGridCard = memo(
flexDirection: 'column', flexDirection: 'column',
m: 0.25, m: 0.25,
outline: selected ? '4px solid' : undefined, outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined, borderRadius: MATERIAL_YOU_VISUALS.cardRadius,
outlineColor: (theme) => theme.palette.primary.main, outlineColor: (theme) => theme.palette.primary.main,
backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined), backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined),
transition: `transform ${MATERIAL_YOU_VISUALS.standardTransition}, filter ${MATERIAL_YOU_VISUALS.standardTransition}`,
'@media (hover: hover) and (pointer: fine)': { '@media (hover: hover) and (pointer: fine)': {
'&:hover': {
transform: `translateY(${MATERIAL_YOU_VISUALS.cardHoverLift}px)`,
filter: 'drop-shadow(0 12px 18px rgba(0, 0, 0, 0.18))',
},
'&:hover .manga-option-button': { '&:hover .manga-option-button': {
visibility: 'visible', visibility: 'visible',
pointerEvents: 'all', pointerEvents: 'all',
@@ -100,6 +106,7 @@ export const MangaGridCard = memo(
// force standard aspect ratio of manga covers // force standard aspect ratio of manga covers
aspectRatio: MANGA_COVER_ASPECT_RATIO, aspectRatio: MANGA_COVER_ASPECT_RATIO,
display: 'flex', display: 'flex',
borderRadius: MATERIAL_YOU_VISUALS.cardRadius,
}} }}
> >
<CardActionArea <CardActionArea

View File

@@ -44,7 +44,6 @@ import type {
MangaTitleInfo, MangaTitleInfo,
MangaTrackRecordInfo, MangaTrackRecordInfo,
} from '@/features/manga/Manga.types.ts'; } from '@/features/manga/Manga.types.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { CustomIconButton } from '@/base/components/buttons/CustomIconButton.tsx'; import { CustomIconButton } from '@/base/components/buttons/CustomIconButton.tsx';
import { Sources } from '@/features/source/services/Sources.ts'; import { Sources } from '@/features/source/services/Sources.ts';
import type { SourceIdInfo } from '@/features/source/Source.types.ts'; import type { SourceIdInfo } from '@/features/source/Source.types.ts';
@@ -56,12 +55,13 @@ import { IconBrowser } from '@/assets/icons/IconBrowser.tsx';
import { IconWebView } from '@/assets/icons/IconWebView.tsx'; import { IconWebView } from '@/assets/icons/IconWebView.tsx';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx'; import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { copyToClipboard } from '@/lib/HelperFunctions.ts'; import { copyToClipboard } from '@/lib/HelperFunctions.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const DetailsWrapper = styled('div')(({ theme }) => ({ const DetailsWrapper = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: theme.spacing(2), gap: theme.spacing(3),
padding: theme.spacing(1), padding: theme.spacing(2),
[theme.breakpoints.up('md')]: { [theme.breakpoints.up('md')]: {
flexBasis: '40%', flexBasis: '40%',
height: 'calc(100vh - 64px)', height: 'calc(100vh - 64px)',
@@ -81,6 +81,10 @@ const TopContentWrapper = ({
<Stack <Stack
sx={{ sx={{
position: 'relative', position: 'relative',
overflow: 'hidden',
borderRadius: `${MATERIAL_YOU_VISUALS.detailRadius}px`,
backgroundColor: 'background.paper',
p: { xs: 1.5, sm: 2 },
}} }}
> >
{mangaThumbnailBackdrop && ( {mangaThumbnailBackdrop && (
@@ -103,24 +107,23 @@ const TopContentWrapper = ({
width: '100%', width: '100%',
height: '100%', height: '100%',
objectFit: 'cover', objectFit: 'cover',
opacity: 0.18,
filter: 'blur(24px) saturate(1.2)',
transform: 'scale(1.12)',
}} }}
src={url} src={url}
alt="Manga Thumbnail" alt="Manga Thumbnail"
/> />
<Stack <Stack
sx={{ sx={{
'&::before': (theme) => '&::before': (theme) => ({
applyStyles(mangaThumbnailBackdrop, { position: 'absolute',
position: 'absolute', display: 'inline-block',
display: 'inline-block', content: '""',
content: '""', inset: 0,
top: 0, backgroundColor: theme.alpha(theme.palette.background.paper, 0.7),
left: 0, backdropFilter: 'blur(10px)',
width: '100%', }),
height: '100%',
background: `linear-gradient(to top, ${theme.palette.background.default}, transparent 100%, transparent 1px),linear-gradient(to right, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to bottom, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to left, ${theme.palette.background.default}, transparent 50%, transparent 1px)`,
backdropFilter: 'blur(4.5px) brightness(0.75)',
}),
}} }}
/> />
</Stack> </Stack>
@@ -131,19 +134,21 @@ const TopContentWrapper = ({
const ThumbnailMetadataWrapper = styled('div')(({ theme }) => ({ const ThumbnailMetadataWrapper = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
paddingBottom: theme.spacing(1), gap: theme.spacing(2),
paddingBottom: theme.spacing(2),
})); }));
const MetadataContainer = styled('div')(({ theme }) => ({ const MetadataContainer = styled('div')({
zIndex: 1, zIndex: 1,
marginLeft: theme.spacing(1), minWidth: 0,
})); });
const Metadata = (props: ComponentProps<typeof BaseMetadata>) => <BaseMetadata {...props} />; const Metadata = (props: ComponentProps<typeof BaseMetadata>) => <BaseMetadata {...props} />;
const MangaButtonsContainer = styled('div')(({ theme }) => ({ const MangaButtonsContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
gap: theme.spacing(1), gap: theme.spacing(1),
flexWrap: 'wrap',
})); }));
const OpenSourceButton = ({ url }: { url?: string | null }) => { const OpenSourceButton = ({ url }: { url?: string | null }) => {

View File

@@ -7,7 +7,7 @@
*/ */
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { useLayoutEffect, useState } from 'react'; import { useLayoutEffect, useRef, useState } from 'react';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import OpenInFullIcon from '@mui/icons-material/OpenInFull'; import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import Modal from '@mui/material/Modal'; import Modal from '@mui/material/Modal';
@@ -23,6 +23,10 @@ import type { TAppThemeContext } from '@/features/theme/AppTheme.types.ts';
import type { ImageRequest } from '@/lib/requests/RequestManager.ts'; import type { ImageRequest } from '@/lib/requests/RequestManager.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { noOp } from '@/lib/HelperFunctions.ts'; import { noOp } from '@/lib/HelperFunctions.ts';
import {
isDynamicColorResultCurrent,
MATERIAL_YOU_VISUALS,
} from '@/features/theme/services/MaterialYouVisualTokens.ts';
export const Thumbnail = ({ export const Thumbnail = ({
manga, manga,
@@ -39,6 +43,8 @@ export const Thumbnail = ({
const [isImageReady, setIsImageReady] = useState(false); const [isImageReady, setIsImageReady] = useState(false);
const url = Mangas.getThumbnailUrl(manga); const url = Mangas.getThumbnailUrl(manga);
const latestUrlRef = useRef(url);
latestUrlRef.current = url;
useLayoutEffect(() => { useLayoutEffect(() => {
if (!mangaDynamicColorSchemes) { if (!mangaDynamicColorSchemes) {
@@ -56,7 +62,7 @@ export const Thumbnail = ({
imageRequest = await requestManager.requestImage(url); imageRequest = await requestManager.requestImage(url);
const image = await imageRequest.response; const image = await imageRequest.response;
if (aborted) { if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return; return;
} }
@@ -65,7 +71,7 @@ export const Thumbnail = ({
img.src = image; img.src = image;
img.onload = () => { img.onload = () => {
if (aborted) { if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return; return;
} }
@@ -82,7 +88,7 @@ export const Thumbnail = ({
], ],
}), }),
]).then(([palette, averageColor]) => { ]).then(([palette, averageColor]) => {
if (aborted) { if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return; return;
} }
@@ -101,7 +107,7 @@ export const Thumbnail = ({
...palette, ...palette,
average: averageColor, average: averageColor,
} as TAppThemeContext['dynamicColor']); } as TAppThemeContext['dynamicColor']);
}); }).catch(noOp);
}; };
}; };
@@ -120,7 +126,7 @@ export const Thumbnail = ({
<Stack <Stack
sx={{ sx={{
position: 'relative', position: 'relative',
borderRadius: 1, borderRadius: `${MATERIAL_YOU_VISUALS.cardRadius}px`,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'background.paper', backgroundColor: 'background.paper',
width: '125px', width: '125px',

View File

@@ -35,6 +35,7 @@ import {
useReaderStore, useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts'; } from '@/features/reader/stores/ReaderStore.ts';
import { copyToClipboard } from '@/lib/HelperFunctions.ts'; import { copyToClipboard } from '@/lib/HelperFunctions.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const DEFAULT_MANGA = { ...FALLBACK_MANGA, title: '' }; const DEFAULT_MANGA = { ...FALLBACK_MANGA, title: '' };
@@ -61,9 +62,12 @@ const BaseReaderOverlayHeaderMobile = ({ isVisible, ref }: MobileHeaderProps & {
right: `${scrollbar.ySize}px`, right: `${scrollbar.ySize}px`,
p: 2, p: 2,
pt: (theme) => `max(env(safe-area-inset-top), ${theme.spacing(2)})`, pt: (theme) => `max(env(safe-area-inset-top), ${theme.spacing(2)})`,
backgroundColor: (theme) => theme.alpha(theme.palette.background.paper, 0.95), backgroundColor: (theme) =>
theme.alpha(theme.palette.background.paper, MATERIAL_YOU_VISUALS.readerChromeOpacity),
backdropFilter: 'blur(20px) saturate(1.15)',
pointerEvents: 'all', pointerEvents: 'all',
boxShadow: 2, borderBottom: '1px solid',
borderColor: 'divider',
}} }}
> >
<ReaderExitButton /> <ReaderExitButton />

View File

@@ -34,6 +34,7 @@ import {
useReaderSettingsStore, useReaderSettingsStore,
useReaderStore, useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts'; } from '@/features/reader/stores/ReaderStore.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const useGetPreviousNavBarStaticValue = (isVisible: boolean, isStaticNav: boolean) => { const useGetPreviousNavBarStaticValue = (isVisible: boolean, isStaticNav: boolean) => {
const wasNavBarStaticRef = useRef(isStaticNav); const wasNavBarStaticRef = useRef(isStaticNav);
@@ -104,6 +105,12 @@ const BaseReaderNavBarDesktop = ({
slotProps={{ slotProps={{
paper: { paper: {
ref: (ref: HTMLDivElement | null) => setNavBarElement(ref), ref: (ref: HTMLDivElement | null) => setNavBarElement(ref),
sx: {
m: 1,
height: 'calc(100% - 16px)',
borderRadius: `${MATERIAL_YOU_VISUALS.detailRadius}px`,
overflow: 'hidden',
},
}, },
transition: { transition: {
unmountOnExit: true, unmountOnExit: true,
@@ -111,7 +118,7 @@ const BaseReaderNavBarDesktop = ({
}} }}
> >
<ReaderNavContainer sx={{ backgroundColor: 'background.paper', pointerEvents: 'all' }}> <ReaderNavContainer sx={{ backgroundColor: 'background.paper', pointerEvents: 'all' }}>
<Stack sx={{ p: 2, gap: 2, backgroundColor: 'action.hover' }}> <Stack sx={{ p: 2, gap: 2, backgroundColor: 'action.selected' }}>
<Stack sx={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}> <Stack sx={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<ReaderExitButton /> <ReaderExitButton />
<CustomTooltip title={t`Static navigation`}> <CustomTooltip title={t`Static navigation`}>

View File

@@ -24,6 +24,7 @@ import { ReaderChapterList } from '@/features/reader/overlay/navigation/componen
import { ReaderBottomBarMobileQuickSettings } from '@/features/reader/overlay/navigation/mobile/quick-settings/ReaderBottomBarMobileQuickSettings.tsx'; import { ReaderBottomBarMobileQuickSettings } from '@/features/reader/overlay/navigation/mobile/quick-settings/ReaderBottomBarMobileQuickSettings.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx'; import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import { useReaderChaptersStore, useReaderScrollbarStore } from '@/features/reader/stores/ReaderStore.ts'; import { useReaderChaptersStore, useReaderScrollbarStore } from '@/features/reader/stores/ReaderStore.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const BaseReaderBottomBarMobile = ({ const BaseReaderBottomBarMobile = ({
openSettings, openSettings,
@@ -68,9 +69,13 @@ const BaseReaderBottomBarMobile = ({
ref={bottomBarRef} ref={bottomBarRef}
sx={{ sx={{
alignItems: 'center', alignItems: 'center',
backgroundColor: (theme) => theme.alpha(theme.palette.background.paper, 0.95), backgroundColor: (theme) =>
theme.alpha(theme.palette.background.paper, MATERIAL_YOU_VISUALS.readerChromeOpacity),
backdropFilter: 'blur(20px) saturate(1.15)',
pb: `max(${scrollbar.xSize}px, env(safe-area-inset-bottom))`, pb: `max(${scrollbar.xSize}px, env(safe-area-inset-bottom))`,
boxShadow: 2, borderTop: '1px solid',
borderColor: 'divider',
borderRadius: '28px 28px 0 0',
pointerEvents: 'all', pointerEvents: 'all',
}} }}
> >

View File

@@ -24,6 +24,7 @@ describe('createMaterialYouThemeOptions', () => {
assert.equal(options.components.MuiButton.styleOverrides.root.borderRadius, 999); assert.equal(options.components.MuiButton.styleOverrides.root.borderRadius, 999);
assert.equal(options.components.MuiButton.styleOverrides.root.textTransform, 'none'); assert.equal(options.components.MuiButton.styleOverrides.root.textTransform, 'none');
assert.equal(options.components.MuiChip.styleOverrides.root.borderRadius, 999); assert.equal(options.components.MuiChip.styleOverrides.root.borderRadius, 999);
assert.equal(options.components.MuiListItemButton.styleOverrides.root.borderRadius, 16);
}); });
it('uses tonal surfaces instead of elevated app chrome', () => { it('uses tonal surfaces instead of elevated app chrome', () => {

View File

@@ -121,6 +121,20 @@ export const createMaterialYouThemeOptions = (scheme: MaterialYouScheme) => ({
root: { borderRadius: 16 }, root: { borderRadius: 16 },
}, },
}, },
MuiListItemButton: {
styleOverrides: {
root: {
minHeight: 48,
marginBlock: 2,
borderRadius: 16,
transition: `background-color ${MATERIAL_YOU_MOTION.standard}`,
'&.Mui-selected': {
backgroundColor: scheme.primaryContainer,
color: scheme.onPrimaryContainer,
},
},
},
},
MuiAppBar: { MuiAppBar: {
defaultProps: { elevation: 0, color: 'transparent' as const }, defaultProps: { elevation: 0, color: 'transparent' as const },
styleOverrides: { styleOverrides: {

View File

@@ -0,0 +1,29 @@
/*
* 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 {
isDynamicColorResultCurrent,
MATERIAL_YOU_VISUALS,
} from '@/features/theme/services/MaterialYouVisualTokens.ts';
describe('Material You visual tokens', () => {
it('defines lifted cards and low-distraction reader chrome', () => {
assert.equal(MATERIAL_YOU_VISUALS.cardRadius, 20);
assert.equal(MATERIAL_YOU_VISUALS.cardHoverLift, -4);
assert.equal(MATERIAL_YOU_VISUALS.detailRadius, 24);
assert.equal(MATERIAL_YOU_VISUALS.readerChromeOpacity, 0.92);
});
it('rejects stale or aborted dynamic-color work', () => {
assert.equal(isDynamicColorResultCurrent('/cover/1', '/cover/1', false), true);
assert.equal(isDynamicColorResultCurrent('/cover/1', '/cover/2', false), false);
assert.equal(isDynamicColorResultCurrent('/cover/1', '/cover/1', true), false);
});
});

View File

@@ -0,0 +1,21 @@
/*
* 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/.
*/
export const MATERIAL_YOU_VISUALS = {
cardRadius: 20,
cardHoverLift: -4,
detailRadius: 24,
readerChromeOpacity: 0.92,
standardTransition: '180ms cubic-bezier(0.2, 0, 0, 1)',
} as const;
export const isDynamicColorResultCurrent = (
requestedUrl: string,
currentUrl: string,
aborted: boolean,
): boolean => !aborted && requestedUrl === currentUrl;