From a0fd55eb37e8acf21f7d61438ba759e735881a97 Mon Sep 17 00:00:00 2001 From: Cho-P4 Date: Thu, 23 Jul 2026 01:07:18 +0800 Subject: [PATCH] feat(ui): restyle core views with Material You --- .../manga/components/cards/MangaGridCard.tsx | 9 +++- .../manga/components/details/MangaDetails.tsx | 43 +++++++++++-------- .../manga/components/details/Thumbnail.tsx | 18 +++++--- .../mobile/ReaderOverlayHeaderMobile.tsx | 8 +++- .../desktop/ReaderNavBarDesktop.tsx | 9 +++- .../mobile/ReaderBottomBarMobile.tsx | 9 +++- .../services/MaterialYouThemeOptions.test.ts | 1 + .../theme/services/MaterialYouThemeOptions.ts | 14 ++++++ .../services/MaterialYouVisualTokens.test.ts | 29 +++++++++++++ .../theme/services/MaterialYouVisualTokens.ts | 21 +++++++++ 10 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 src/features/theme/services/MaterialYouVisualTokens.test.ts create mode 100644 src/features/theme/services/MaterialYouVisualTokens.ts diff --git a/src/features/manga/components/cards/MangaGridCard.tsx b/src/features/manga/components/cards/MangaGridCard.tsx index 4a282eaf..670178d2 100644 --- a/src/features/manga/components/cards/MangaGridCard.tsx +++ b/src/features/manga/components/cards/MangaGridCard.tsx @@ -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, }} > ({ 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 = ({ {mangaThumbnailBackdrop && ( @@ -103,24 +107,23 @@ 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" /> - applyStyles(mangaThumbnailBackdrop, { - 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)', - }), + '&::before': (theme) => ({ + position: 'absolute', + display: 'inline-block', + content: '""', + 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) => ; const MangaButtonsContainer = styled('div')(({ theme }) => ({ display: 'flex', gap: theme.spacing(1), + flexWrap: 'wrap', })); const OpenSourceButton = ({ url }: { url?: string | null }) => { diff --git a/src/features/manga/components/details/Thumbnail.tsx b/src/features/manga/components/details/Thumbnail.tsx index 80d8bf7d..8c973452 100644 --- a/src/features/manga/components/details/Thumbnail.tsx +++ b/src/features/manga/components/details/Thumbnail.tsx @@ -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 = ({ `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', }} > diff --git a/src/features/reader/overlay/navigation/desktop/ReaderNavBarDesktop.tsx b/src/features/reader/overlay/navigation/desktop/ReaderNavBarDesktop.tsx index cfaf82a9..8f27f561 100644 --- a/src/features/reader/overlay/navigation/desktop/ReaderNavBarDesktop.tsx +++ b/src/features/reader/overlay/navigation/desktop/ReaderNavBarDesktop.tsx @@ -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 = ({ }} > - + diff --git a/src/features/reader/overlay/navigation/mobile/ReaderBottomBarMobile.tsx b/src/features/reader/overlay/navigation/mobile/ReaderBottomBarMobile.tsx index 7dbe9e47..a78778d2 100644 --- a/src/features/reader/overlay/navigation/mobile/ReaderBottomBarMobile.tsx +++ b/src/features/reader/overlay/navigation/mobile/ReaderBottomBarMobile.tsx @@ -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', }} > diff --git a/src/features/theme/services/MaterialYouThemeOptions.test.ts b/src/features/theme/services/MaterialYouThemeOptions.test.ts index 2176dd02..7c893f25 100644 --- a/src/features/theme/services/MaterialYouThemeOptions.test.ts +++ b/src/features/theme/services/MaterialYouThemeOptions.test.ts @@ -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', () => { diff --git a/src/features/theme/services/MaterialYouThemeOptions.ts b/src/features/theme/services/MaterialYouThemeOptions.ts index 0158463c..688e060d 100644 --- a/src/features/theme/services/MaterialYouThemeOptions.ts +++ b/src/features/theme/services/MaterialYouThemeOptions.ts @@ -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: { diff --git a/src/features/theme/services/MaterialYouVisualTokens.test.ts b/src/features/theme/services/MaterialYouVisualTokens.test.ts new file mode 100644 index 00000000..7a963bed --- /dev/null +++ b/src/features/theme/services/MaterialYouVisualTokens.test.ts @@ -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); + }); +}); diff --git a/src/features/theme/services/MaterialYouVisualTokens.ts b/src/features/theme/services/MaterialYouVisualTokens.ts new file mode 100644 index 00000000..6dd603a6 --- /dev/null +++ b/src/features/theme/services/MaterialYouVisualTokens.ts @@ -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;