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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ import {
useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts';
import { copyToClipboard } from '@/lib/HelperFunctions.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const DEFAULT_MANGA = { ...FALLBACK_MANGA, title: '' };
@@ -61,9 +62,12 @@ const BaseReaderOverlayHeaderMobile = ({ isVisible, ref }: MobileHeaderProps & {
right: `${scrollbar.ySize}px`,
p: 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',
boxShadow: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<ReaderExitButton />

View File

@@ -34,6 +34,7 @@ import {
useReaderSettingsStore,
useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const useGetPreviousNavBarStaticValue = (isVisible: boolean, isStaticNav: boolean) => {
const wasNavBarStaticRef = useRef(isStaticNav);
@@ -104,6 +105,12 @@ const BaseReaderNavBarDesktop = ({
slotProps={{
paper: {
ref: (ref: HTMLDivElement | null) => setNavBarElement(ref),
sx: {
m: 1,
height: 'calc(100% - 16px)',
borderRadius: `${MATERIAL_YOU_VISUALS.detailRadius}px`,
overflow: 'hidden',
},
},
transition: {
unmountOnExit: true,
@@ -111,7 +118,7 @@ const BaseReaderNavBarDesktop = ({
}}
>
<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' }}>
<ReaderExitButton />
<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 { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import { useReaderChaptersStore, useReaderScrollbarStore } from '@/features/reader/stores/ReaderStore.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const BaseReaderBottomBarMobile = ({
openSettings,
@@ -68,9 +69,13 @@ const BaseReaderBottomBarMobile = ({
ref={bottomBarRef}
sx={{
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))`,
boxShadow: 2,
borderTop: '1px solid',
borderColor: 'divider',
borderRadius: '28px 28px 0 0',
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.textTransform, 'none');
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', () => {

View File

@@ -121,6 +121,20 @@ export const createMaterialYouThemeOptions = (scheme: MaterialYouScheme) => ({
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: {
defaultProps: { elevation: 0, color: 'transparent' as const },
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;