feat(ui): restyle core views with Material You
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
29
src/features/theme/services/MaterialYouVisualTokens.test.ts
Normal file
29
src/features/theme/services/MaterialYouVisualTokens.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
src/features/theme/services/MaterialYouVisualTokens.ts
Normal file
21
src/features/theme/services/MaterialYouVisualTokens.ts
Normal 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;
|
||||
Reference in New Issue
Block a user