mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
ui redesign
This commit is contained in:
1 parent
d0a027a2a1
commit
67da3061a6
49 files changed
+1496
-458
No files matched your search
@@ -8,6 +8,7 @@
|
||||
"@catppuccin/palette": "^1.7.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
||||
"@mui/icons-material": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@tanstack/react-query": "^5.100.3",
|
||||
@@ -213,6 +214,8 @@
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
|
||||
"@fontsource-variable/plus-jakarta-sans": ["@fontsource-variable/plus-jakarta-sans@5.3.0", "", {}, "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
||||
+35
-16
@@ -8,23 +8,42 @@
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0;">
|
||||
<script>
|
||||
(function() {
|
||||
const theme = localStorage.getItem('theme') || 'holy_light';
|
||||
const darkThemes = ['holy_dark', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'gruvbox', 'oceanic', 't0p_trench', 't0p_blurryface', 't0p_clancy'];
|
||||
const isDark = darkThemes.includes(theme);
|
||||
|
||||
// Define primary background colors for common themes to prevent flash
|
||||
const bgColors = {
|
||||
'holy_light': '#f8fafc',
|
||||
'holy_dark': '#020617',
|
||||
'pink_pastel': '#fff1f2',
|
||||
'mocha': '#1e1e2e',
|
||||
't0p_sai': '#fdf2f8',
|
||||
't0p_trench': '#1a1a1a'
|
||||
// Paints the page ground before React mounts so there's no white flash.
|
||||
// Kept deliberately in sync with getTheme() in src/theme.ts — when a
|
||||
// theme is added there, add its base colour here too, or it falls back to
|
||||
// a generic light/dark grey for the first frame.
|
||||
(function () {
|
||||
const bg = {
|
||||
holy_light: '#f8fafc',
|
||||
holy_dark: '#020617',
|
||||
latte: '#eff1f5',
|
||||
frappe: '#303446',
|
||||
macchiato: '#24273a',
|
||||
mocha: '#1e1e2e',
|
||||
pink_pastel: '#fff1f2',
|
||||
mint_pastel: '#f0fdf4',
|
||||
lavender_pastel: '#f5f3ff',
|
||||
dracula: '#282a36',
|
||||
nord: '#2e3440',
|
||||
gruvbox: '#282828',
|
||||
oceanic: '#1b262c',
|
||||
t0p_sai: '#fdf2f8',
|
||||
t0p_trench: '#1a1a1a',
|
||||
t0p_blurryface: '#000000',
|
||||
t0p_clancy: '#000000',
|
||||
};
|
||||
|
||||
document.body.style.backgroundColor = bgColors[theme] || (isDark ? '#0f172a' : '#f8fafc');
|
||||
document.body.style.color = isDark ? '#f8fafc' : '#0f172a';
|
||||
const light = [
|
||||
'holy_light',
|
||||
'latte',
|
||||
'pink_pastel',
|
||||
'mint_pastel',
|
||||
'lavender_pastel',
|
||||
't0p_sai',
|
||||
];
|
||||
const theme = localStorage.getItem('theme') || 'holy_light';
|
||||
const isLight = light.includes(theme);
|
||||
document.body.style.backgroundColor = bg[theme] || (isLight ? '#f8fafc' : '#0f172a');
|
||||
document.body.style.color = isLight ? '#0f172a' : '#f8fafc';
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@catppuccin/palette": "^1.7.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
||||
"@mui/icons-material": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@tanstack/react-query": "^5.100.3",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
+32
-15
@@ -7,7 +7,10 @@ import CookieBanner from './components/CookieBanner';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { TopBar } from './components/layout/TopBar';
|
||||
import { CategoryBar } from './components/layout/CategoryBar';
|
||||
import { AppBackdrop } from './components/ui';
|
||||
import { BackdropArtProvider } from './contexts/BackdropArtContext';
|
||||
import { useBackdropArtValue } from './hooks/useBackdropArt';
|
||||
import { useGlassCapability } from './hooks/useGlassCapability';
|
||||
import { useCategories } from './api/queries/useCategories';
|
||||
import { RequireSuperuser } from './components/auth/RequireSuperuser';
|
||||
import type { CatppuccinTheme } from './theme';
|
||||
@@ -37,6 +40,11 @@ const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
|
||||
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
|
||||
|
||||
/** Reads whatever art the current route published and feeds the fixed backdrop. */
|
||||
const BackdropSlot: React.FC<{ reducedMotion: boolean }> = ({ reducedMotion }) => (
|
||||
<AppBackdrop art={useBackdropArtValue()} reducedMotion={reducedMotion} />
|
||||
);
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const muiTheme = useMuiTheme();
|
||||
@@ -44,8 +52,13 @@ const App: React.FC = () => {
|
||||
const { themeName, handleThemeChange } = useTheme();
|
||||
|
||||
const { data: categories = [] } = useCategories();
|
||||
// Resolves the refract/blur/flat tier and stamps `<html data-glass>`, which
|
||||
// every rim rule in Glass.tsx keys off.
|
||||
const { reducedMotion } = useGlassCapability();
|
||||
|
||||
useEffect(() => {
|
||||
// The fixed backdrop paints over this; it exists so the page ground is
|
||||
// right before React mounts and behind any overscroll.
|
||||
document.body.style.backgroundColor = muiTheme.palette.background.default;
|
||||
document.body.style.color = muiTheme.palette.text.primary;
|
||||
}, [muiTheme]);
|
||||
@@ -55,19 +68,23 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<TopBar />
|
||||
<BackdropArtProvider>
|
||||
{/* No transform/filter/opacity may be introduced on this element or on
|
||||
#root: either would create a stacking context that isolates the fixed
|
||||
backdrop, and every backdrop-filter in the app would sample nothing
|
||||
and go flat. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<BackdropSlot reducedMotion={reducedMotion} />
|
||||
|
||||
<CategoryBar categories={categories} />
|
||||
<TopBar categories={categories} />
|
||||
|
||||
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
@@ -143,10 +160,10 @@ const App: React.FC = () => {
|
||||
</Box>
|
||||
<Footer />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} />
|
||||
</Box>
|
||||
<CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} />
|
||||
</Box>
|
||||
</BackdropArtProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,12 +6,15 @@ import { AuthProvider } from '../contexts/AuthContext';
|
||||
import { NotificationProvider } from '../contexts/NotificationContext';
|
||||
import { ToastProvider } from '../contexts/ToastContext';
|
||||
import { ConfirmProvider } from '../contexts/ConfirmContext';
|
||||
import { GlassDefs } from '../components/ui/GlassDefs';
|
||||
import { queryClient } from './queryClient';
|
||||
import '../i18n';
|
||||
|
||||
export const AppProviders = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
{/* Mounted once so `url(#hfr-…)` resolves for every glass rim in the app. */}
|
||||
<GlassDefs />
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogActions,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CatppuccinTheme } from '../theme';
|
||||
|
||||
@@ -45,7 +46,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
|
||||
backdropFilter: 'blur(10px)',
|
||||
backdropFilter: (theme: Theme) => theme.tokens.glass.blurStrong,
|
||||
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -10,14 +10,18 @@ const Footer: React.FC = () => {
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
sx={(theme) => ({
|
||||
// Was fully opaque, which cut the page's backdrop off in a hard band at
|
||||
// the fold. Translucent keeps the aurora running to the bottom edge.
|
||||
bgcolor: theme.tokens.glass.tintSubtle,
|
||||
backdropFilter: theme.tokens.glass.blur,
|
||||
WebkitBackdropFilter: theme.tokens.glass.blur,
|
||||
pt: 6,
|
||||
pb: 4,
|
||||
mt: 'auto',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box
|
||||
|
||||
@@ -168,7 +168,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
style={{ zIndex: 2000, width: 250 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
|
||||
@@ -45,7 +45,15 @@ export const BrandMark = ({ onNavigate, compact = false }: BrandMarkProps) => {
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ fontWeight: 900, fontSize: '1.15rem', lineHeight: 1, color: '#fff' }}
|
||||
// The tile is the primary gradient, so the monogram must use the
|
||||
// contrast colour the palette computes for it — a literal white
|
||||
// disappears on the light-accent themes (latte, t0p_sai, mint).
|
||||
sx={{
|
||||
fontWeight: 900,
|
||||
fontSize: '1.15rem',
|
||||
lineHeight: 1,
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
H
|
||||
</Typography>
|
||||
|
||||
@@ -12,10 +12,13 @@ export interface CategoryBarProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal nav pinned just under the top bar on every breakpoint. Categories
|
||||
* are the dominant browse axis — laid out as a scrollable row of tabs — with
|
||||
* Home / Community / Dashboard as primary links on the left. Replaces the old
|
||||
* left rail on desktop and mirrors the same sticky chrome on mobile.
|
||||
* The browse row. Categories are the dominant browse axis, so they get a
|
||||
* scrollable row of their own with Home / Community / Dashboard leading it.
|
||||
*
|
||||
* This renders as the *second row inside* the nav pane (see `GlassAppBar`) —
|
||||
* it deliberately carries no sticky positioning or glass of its own, so the
|
||||
* two rows share one pane, one rim and one blur pass instead of stacking two
|
||||
* translucent bars on top of each other.
|
||||
*/
|
||||
export const CategoryBar = ({ categories }: CategoryBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -27,12 +30,16 @@ export const CategoryBar = ({ categories }: CategoryBarProps) => {
|
||||
|
||||
const tabSx = (active: boolean) => ({
|
||||
flexShrink: 0,
|
||||
borderRadius: 2,
|
||||
borderRadius: (theme: Theme) => `${theme.tokens.radius.pill}px`,
|
||||
px: 1.75,
|
||||
py: 0.75,
|
||||
fontWeight: active ? 700 : 500,
|
||||
color: active ? 'primary.main' : 'text.secondary',
|
||||
bgcolor: active ? (theme: Theme) => alpha(theme.palette.primary.main, 0.12) : 'transparent',
|
||||
bgcolor: active ? (theme: Theme) => alpha(theme.palette.primary.main, 0.14) : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: active ? (theme: Theme) => alpha(theme.palette.primary.main, 0.35) : 'transparent',
|
||||
transition: (theme: Theme) =>
|
||||
`background-color ${theme.tokens.motion.fast} ${theme.tokens.motion.ease}, border-color ${theme.tokens.motion.fast} ${theme.tokens.motion.ease}`,
|
||||
'&:hover': {
|
||||
color: 'text.primary',
|
||||
bgcolor: 'action.hover',
|
||||
@@ -43,80 +50,66 @@ export const CategoryBar = ({ categories }: CategoryBarProps) => {
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: { xs: 56, sm: 64 },
|
||||
zIndex: (theme) => theme.zIndex.appBar - 1,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: (theme) => theme.tokens.glass.border,
|
||||
backgroundColor: (theme) =>
|
||||
alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.8 : 0.6),
|
||||
backdropFilter: (theme) => theme.tokens.glass.blurStrong,
|
||||
WebkitBackdropFilter: (theme) => theme.tokens.glass.blurStrong,
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
px: { xs: 1, sm: 4, md: 6 },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { xs: 0.5, md: 1 },
|
||||
minHeight: { xs: 46, md: 52 },
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
disableRipple
|
||||
sx={{ ...tabSx(isActive('/')), ml: { xs: -0.5, md: -1.75 } }}
|
||||
>
|
||||
<HomeOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
|
||||
{t('nav.home')}
|
||||
</Button>
|
||||
|
||||
{user && (
|
||||
<Button component={Link} to="/community" disableRipple sx={tabSx(isActive('/community'))}>
|
||||
<GroupsOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
|
||||
{t('nav.community')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<Button component={Link} to="/dashboard" disableRipple sx={tabSx(isActive('/dashboard'))}>
|
||||
<CollectionsBookmarkOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
px: { xs: 1, sm: 4, md: 6 },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { xs: 0.5, md: 1 },
|
||||
minHeight: { xs: 46, md: 52 },
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
alignSelf: 'stretch',
|
||||
my: 1.25,
|
||||
mx: 0.5,
|
||||
borderLeft: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
disableRipple
|
||||
sx={{ ...tabSx(isActive('/')), ml: { xs: -0.5, md: -1.75 } }}
|
||||
>
|
||||
<HomeOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
|
||||
{t('nav.home')}
|
||||
</Button>
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<Button component={Link} to="/community" disableRipple sx={tabSx(isActive('/community'))}>
|
||||
<GroupsOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
|
||||
{t('nav.community')}
|
||||
{categories.map((cat) => {
|
||||
const path = `/category/${cat.slug}`;
|
||||
return (
|
||||
<Button
|
||||
key={cat.slug}
|
||||
component={Link}
|
||||
to={path}
|
||||
disableRipple
|
||||
sx={tabSx(location.pathname === path)}
|
||||
>
|
||||
{t(`categories.${cat.slug}`, { defaultValue: cat.name })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<Button component={Link} to="/dashboard" disableRipple sx={tabSx(isActive('/dashboard'))}>
|
||||
<CollectionsBookmarkOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
alignSelf: 'stretch',
|
||||
my: 1.25,
|
||||
mx: 0.5,
|
||||
borderLeft: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
/>
|
||||
|
||||
{categories.map((cat) => {
|
||||
const path = `/category/${cat.slug}`;
|
||||
return (
|
||||
<Button
|
||||
key={cat.slug}
|
||||
component={Link}
|
||||
to={path}
|
||||
disableRipple
|
||||
sx={tabSx(location.pathname === path)}
|
||||
>
|
||||
{t(`categories.${cat.slug}`, { defaultValue: cat.name })}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -22,20 +22,25 @@ import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettin
|
||||
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GlassAppBar } from '../ui';
|
||||
import { CategoryBar } from './CategoryBar';
|
||||
import { GlobalSearch } from '../../app/GlobalSearch';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { BrandMark } from './BrandMark';
|
||||
import { NotificationMenu } from './NotificationMenu';
|
||||
|
||||
export interface TopBarProps {
|
||||
categories: { name: string; slug: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Top bar for every breakpoint. Promotes global search to the primary, centered
|
||||
* slot; notifications + an account menu sit on the right. Everything that used to
|
||||
* be a flat sidebar row (profile, dashboard, settings, support, admin, following,
|
||||
* logout) lives behind the avatar menu — so there's no mobile drawer anymore, the
|
||||
* same dropdown serves all sizes. Browse links (home/community/categories) live
|
||||
* in the CategoryBar below.
|
||||
* in the CategoryBar, which now rides as the second row of this same glass pane.
|
||||
*/
|
||||
export const TopBar = () => {
|
||||
export const TopBar = ({ categories }: TopBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { user, following, logout } = useAuth();
|
||||
|
||||
@@ -49,6 +54,7 @@ export const TopBar = () => {
|
||||
return (
|
||||
<GlassAppBar
|
||||
toolbarSx={{ maxWidth: 1400, width: '100%', mx: 'auto', px: { xs: 1, sm: 4, md: 6 } }}
|
||||
secondary={<CategoryBar categories={categories} />}
|
||||
>
|
||||
<BrandMark />
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { alpha, keyframes, styled } from '@mui/material/styles';
|
||||
import { BannerPerformanceWrapper } from '../banners';
|
||||
import { GRAIN_DATA_URI } from './GlassDefs';
|
||||
|
||||
/**
|
||||
* The page-wide layer everything else is seen *through*.
|
||||
*
|
||||
* Glass is only interesting with something going on behind it, so this sits
|
||||
* fixed behind the whole app rather than per-page: a themed ground, slow
|
||||
* drifting aurora blobs, an optional canvas art layer for hero routes, and a
|
||||
* fine grain that both kills gradient banding and gives the rim refraction
|
||||
* something to bend.
|
||||
*
|
||||
* Everything animated here moves `transform` only, so it stays on the
|
||||
* compositor; nothing animates `filter` or `background-position`.
|
||||
*/
|
||||
|
||||
const drift = (dx: string, dy: string, scale: number) => keyframes`
|
||||
0% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(${dx}, ${dy}, 0) scale(${scale}); }
|
||||
100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
`;
|
||||
|
||||
const Root = styled(Box)(({ theme }) => ({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
}));
|
||||
|
||||
const Blob = styled(Box)({
|
||||
position: 'absolute',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(70px)',
|
||||
willChange: 'transform',
|
||||
});
|
||||
|
||||
export interface AppBackdropProps {
|
||||
/**
|
||||
* Optional canvas art for hero routes — one of the procedural banners.
|
||||
* Mounted through `BannerPerformanceWrapper`, so it already pauses when
|
||||
* off-screen and halts under `prefers-reduced-motion`.
|
||||
*/
|
||||
art?: ReactNode;
|
||||
/** Pauses the aurora drift. Canvas art gates itself. */
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
export const AppBackdrop = ({ art, reducedMotion = false }: AppBackdropProps) => (
|
||||
<Root aria-hidden="true">
|
||||
{/* Light themes need a much fainter wash: the same alpha that reads as a
|
||||
moody glow on a dark ground turns a light page into solid colour. */}
|
||||
<Blob
|
||||
sx={(theme) => ({
|
||||
top: '-18vh',
|
||||
left: '-10vw',
|
||||
width: '62vw',
|
||||
height: '62vw',
|
||||
background: `radial-gradient(circle at 50% 50%, ${alpha(theme.palette.primary.main, theme.palette.mode === 'light' ? 0.2 : 0.5)} 0%, ${alpha(theme.palette.primary.main, 0)} 68%)`,
|
||||
animation: reducedMotion
|
||||
? 'none'
|
||||
: `${drift('12vw', '9vh', 1.15)} 58s ease-in-out infinite`,
|
||||
})}
|
||||
/>
|
||||
<Blob
|
||||
sx={(theme) => ({
|
||||
top: '10vh',
|
||||
right: '-16vw',
|
||||
width: '58vw',
|
||||
height: '58vw',
|
||||
background: `radial-gradient(circle at 50% 50%, ${alpha(theme.palette.secondary.main, theme.palette.mode === 'light' ? 0.16 : 0.42)} 0%, ${alpha(theme.palette.secondary.main, 0)} 70%)`,
|
||||
animation: reducedMotion
|
||||
? 'none'
|
||||
: `${drift('-10vw', '14vh', 1.22)} 71s ease-in-out infinite`,
|
||||
})}
|
||||
/>
|
||||
<Blob
|
||||
sx={(theme) => ({
|
||||
bottom: '-24vh',
|
||||
left: '22vw',
|
||||
width: '70vw',
|
||||
height: '70vw',
|
||||
background: `radial-gradient(circle at 50% 50%, ${alpha(theme.palette.primary.main, theme.palette.mode === 'light' ? 0.12 : 0.3)} 0%, ${alpha(theme.palette.secondary.main, 0)} 72%)`,
|
||||
animation: reducedMotion
|
||||
? 'none'
|
||||
: `${drift('8vw', '-11vh', 1.1)} 47s ease-in-out infinite`,
|
||||
})}
|
||||
/>
|
||||
|
||||
{art && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.32,
|
||||
// Fade the art out below the fold so long pages don't scroll into a
|
||||
// busy background behind body copy.
|
||||
maskImage: 'linear-gradient(180deg, #000 0%, #000 38%, transparent 78%)',
|
||||
WebkitMaskImage: 'linear-gradient(180deg, #000 0%, #000 38%, transparent 78%)',
|
||||
}}
|
||||
>
|
||||
<BannerPerformanceWrapper>{art}</BannerPerformanceWrapper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.035,
|
||||
backgroundImage: GRAIN_DATA_URI,
|
||||
backgroundRepeat: 'repeat',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
import { GlassCard } from './Glass';
|
||||
import { glassSx } from './glass-style';
|
||||
import RatingBadge from '../RatingBadge';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
|
||||
@@ -24,6 +24,17 @@ export interface FlavorCardProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most-rendered surface in the app, so it is also the tightest performance
|
||||
* budget: **exactly one `backdrop-filter` layer per card**, spent on the
|
||||
* floating info panel where it is actually visible. The card body itself skips
|
||||
* the blur whenever an image covers it — blurring the backdrop behind an opaque
|
||||
* photo costs a compositing layer and shows nothing.
|
||||
*
|
||||
* The panel deliberately overlaps the artwork rather than sitting in a separate
|
||||
* block below it: glass over live content is the point, and the product shot is
|
||||
* the only thing on a grid page worth looking through glass at.
|
||||
*/
|
||||
export const FlavorCard = ({
|
||||
flavor,
|
||||
caption,
|
||||
@@ -32,6 +43,8 @@ export const FlavorCard = ({
|
||||
}: FlavorCardProps) => {
|
||||
const theme = useTheme();
|
||||
const hasRating = typeof flavor.average_rating === 'number' && flavor.average_rating > 0;
|
||||
const hasImage = !!flavor.image_url;
|
||||
const inset = compact ? 6 : 8;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -39,32 +52,23 @@ export const FlavorCard = ({
|
||||
style={{ textDecoration: 'none', color: 'inherit', display: 'block', height: '100%' }}
|
||||
>
|
||||
<GlassCard
|
||||
tier="card"
|
||||
intensity="subtle"
|
||||
interactive
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// Width-driven, not height-driven: with `height: 100%` the grid row
|
||||
// stretches the card and `aspect-ratio` then derives a width wider
|
||||
// than the column, so the artwork spills past the rounded corners.
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 200ms ease, box-shadow 200ms ease, background-color 200ms ease',
|
||||
'&:hover': {
|
||||
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||
boxShadow: theme.tokens.elevation.md,
|
||||
'& .flavor-card-image': { transform: 'scale(1.04)' },
|
||||
},
|
||||
aspectRatio: '4 / 5',
|
||||
...(hasImage ? { backdropFilter: 'none', WebkitBackdropFilter: 'none' } : {}),
|
||||
'&:hover .flavor-card-image': { transform: 'scale(1.05)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '1 / 1',
|
||||
bgcolor: 'background.default',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
{flavor.image_url ? (
|
||||
<Box
|
||||
component="img"
|
||||
@@ -76,7 +80,7 @@ export const FlavorCard = ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 600ms ease',
|
||||
transition: `transform ${theme.tokens.motion.slow} ${theme.tokens.motion.ease}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -87,88 +91,92 @@ export const FlavorCard = ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: alpha(theme.palette.primary.main, 0.04),
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||
{flavor.name.charAt(0).toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Bottom fade for legibility of overlays */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 'auto 0 0 0',
|
||||
height: '40%',
|
||||
pointerEvents: 'none',
|
||||
background: `linear-gradient(to top, ${alpha(theme.palette.background.paper, 0.55)} 0%, transparent 100%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status badge — top-right */}
|
||||
<Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}>
|
||||
<StatusBadge
|
||||
isLegacy={flavor.is_legacy}
|
||||
isAvailable={flavor.is_available}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Category chip — bottom-left */}
|
||||
{showCategory && flavor.category_name && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
zIndex: 1,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
px: 0.85,
|
||||
py: 0.35,
|
||||
borderRadius: theme.tokens.radius.pill + 'px',
|
||||
bgcolor: alpha(theme.palette.background.paper, 0.85),
|
||||
backdropFilter: theme.tokens.glass.blur,
|
||||
WebkitBackdropFilter: theme.tokens.glass.blur,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.08),
|
||||
// Tinted, not solid, so the aurora still reads through an
|
||||
// imageless card instead of punching a hole in it.
|
||||
background: `linear-gradient(160deg, ${alpha(theme.palette.primary.main, 0.18)}, ${alpha(theme.palette.secondary.main, 0.1)})`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.6rem',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'text.secondary',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
noWrap
|
||||
variant="h3"
|
||||
sx={{ fontWeight: 800, opacity: 0.35, letterSpacing: '-0.03em' }}
|
||||
>
|
||||
{flavor.category_name}
|
||||
{flavor.name.charAt(0).toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<CardContent
|
||||
<Box sx={{ position: 'absolute', top: inset + 2, right: inset + 2, zIndex: 2 }}>
|
||||
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" />
|
||||
</Box>
|
||||
|
||||
{showCategory && flavor.category_name && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: inset + 2,
|
||||
left: inset + 2,
|
||||
zIndex: 2,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: `calc(100% - ${(inset + 2) * 2}px - 72px)`,
|
||||
px: 0.9,
|
||||
py: 0.4,
|
||||
borderRadius: `${theme.tokens.radius.pill}px`,
|
||||
bgcolor: alpha(theme.palette.background.paper, 0.7),
|
||||
border: '1px solid',
|
||||
borderColor: theme.tokens.glass.border,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.6rem',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'text.secondary',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
noWrap
|
||||
>
|
||||
{flavor.category_name}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* The one glass layer: a panel floating over the artwork.
|
||||
The tint is pushed past the usual `strong` value on purpose — these
|
||||
product shots are vivid and unpredictable, and a standard glass tint
|
||||
leaves the title unreadable over the bright ones. The blur and
|
||||
saturation still pull the artwork's colour up through the panel, so
|
||||
it reads as glass rather than as a solid label. */}
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
...glassSx(theme, { intensity: 'strong', tier: 'card' }),
|
||||
backgroundColor: alpha(
|
||||
theme.palette.background.paper,
|
||||
theme.palette.mode === 'light' ? 0.82 : 0.78,
|
||||
),
|
||||
position: 'absolute',
|
||||
left: inset,
|
||||
right: inset,
|
||||
bottom: inset,
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: compact ? 0.5 : 0.75,
|
||||
p: compact ? 1.5 : 2,
|
||||
'&:last-child': { pb: compact ? 1.5 : 2 },
|
||||
gap: 0.5,
|
||||
px: compact ? 1.15 : 1.5,
|
||||
py: compact ? 0.9 : 1.15,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
fontWeight: 700,
|
||||
fontSize: compact ? '0.9rem' : '1rem',
|
||||
fontSize: compact ? '0.85rem' : '0.98rem',
|
||||
lineHeight: 1.25,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
@@ -177,29 +185,32 @@ export const FlavorCard = ({
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
mt: 'auto',
|
||||
minHeight: 24,
|
||||
}}
|
||||
>
|
||||
{hasRating && <RatingBadge score={flavor.average_rating || 0} size="small" />}
|
||||
{caption && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ fontWeight: 600, fontSize: '0.7rem', ml: 'auto' }}
|
||||
>
|
||||
{caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
{(hasRating || caption) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
minHeight: 22,
|
||||
}}
|
||||
>
|
||||
{hasRating && <RatingBadge score={flavor.average_rating || 0} size="small" />}
|
||||
{caption && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ fontWeight: 600, fontSize: '0.7rem', ml: 'auto' }}
|
||||
>
|
||||
{caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,13 @@ export const FlavorGrid = ({ children, compact = false, sx }: FlavorGridProps) =
|
||||
gap: compact ? 2 : 3,
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
// A long category page can hold 40+ glass cards. Skipping layout and
|
||||
// paint for the off-screen ones keeps the compositor from rasterising
|
||||
// every blur at once.
|
||||
'& > *': {
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: compact ? 'auto 220px' : 'auto 300px',
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const FlavorThumb = ({ src, name, size = 64, radius = 1.5, sx }: FlavorTh
|
||||
flexShrink: 0,
|
||||
borderRadius: radius,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.default',
|
||||
bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.06),
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
|
||||
@@ -9,6 +9,20 @@ const collectStyles = () =>
|
||||
.map((s) => s.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
/**
|
||||
* Emotion keeps one shared style cache for the whole file, so `collectStyles`
|
||||
* also returns rules emitted by earlier tests. Negative assertions have to be
|
||||
* scoped to the rules for *this* element's generated class.
|
||||
*/
|
||||
const cssForElement = (el: HTMLElement) => {
|
||||
const generated = Array.from(el.classList).find((c) => c.startsWith('css-'));
|
||||
if (!generated) throw new Error('element has no emotion class');
|
||||
return collectStyles()
|
||||
.split('}')
|
||||
.filter((rule) => rule.includes(generated))
|
||||
.join('}');
|
||||
};
|
||||
|
||||
const renderWithTheme = (ui: React.ReactElement, mode: CatppuccinTheme = 'mocha') => {
|
||||
const theme = getTheme(mode);
|
||||
const result = render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
|
||||
@@ -61,6 +75,47 @@ describe('Glass primitives', () => {
|
||||
expect(getByTestId('g').getAttribute('intensity')).toBeNull();
|
||||
});
|
||||
|
||||
it('panel tier gates rim refraction behind [data-glass="refract"]', () => {
|
||||
const { theme } = renderWithTheme(<GlassCard data-testid="g">x</GlassCard>);
|
||||
const css = collectStyles();
|
||||
// The ring itself is always present so the blur tier still gets an edge...
|
||||
expect(css).toContain(`${theme.tokens.glass.rimWidth}px solid transparent`);
|
||||
// ...but the displacement filter only applies on engines that render it.
|
||||
expect(css).toContain('[data-glass="refract"]');
|
||||
expect(css).toContain(theme.tokens.glass.rimFilter);
|
||||
});
|
||||
|
||||
it('chrome tier uses the chromatic rim filter and the chrome tint', () => {
|
||||
const { theme } = renderWithTheme(
|
||||
<GlassSurface data-testid="c" tier="chrome">
|
||||
x
|
||||
</GlassSurface>,
|
||||
);
|
||||
const css = collectStyles();
|
||||
expect(css).toContain(theme.tokens.glass.rimFilterChroma);
|
||||
expect(css).toContain(theme.tokens.glass.tintChrome);
|
||||
});
|
||||
|
||||
it('card tier stays on the cheap blur and never adds a rim backdrop-filter', () => {
|
||||
const { getByTestId, theme } = renderWithTheme(
|
||||
<GlassCard data-testid="c" tier="card">
|
||||
x
|
||||
</GlassCard>,
|
||||
);
|
||||
const css = cssForElement(getByTestId('c'));
|
||||
expect(css).toContain(theme.tokens.glass.blurCard);
|
||||
// A grid renders 40+ of these; a second compositing layer per card is the
|
||||
// one thing this tier exists to avoid.
|
||||
expect(css).not.toContain(theme.tokens.glass.rimFilter);
|
||||
});
|
||||
|
||||
it('drops translucency entirely under [data-glass="flat"]', () => {
|
||||
const { theme } = renderWithTheme(<GlassCard data-testid="g">x</GlassCard>);
|
||||
const css = collectStyles();
|
||||
expect(css).toContain('[data-glass="flat"]');
|
||||
expect(css).toContain(theme.palette.background.paper);
|
||||
});
|
||||
|
||||
it('GlassPaper + GlassSurface emit glass tint on a light theme', () => {
|
||||
const { theme } = renderWithTheme(
|
||||
<>
|
||||
|
||||
@@ -1,56 +1,20 @@
|
||||
import { styled, type Theme } from '@mui/material/styles';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import MuiCard from '@mui/material/Card';
|
||||
import MuiPaper from '@mui/material/Paper';
|
||||
import Box from '@mui/material/Box';
|
||||
import { glassSx, type GlassOptions } from './glass-style';
|
||||
|
||||
type Intensity = 'subtle' | 'default' | 'strong';
|
||||
const forwardable = (prop: PropertyKey) =>
|
||||
prop !== 'intensity' && prop !== 'tier' && prop !== 'interactive';
|
||||
|
||||
const intensityStyles =
|
||||
(intensity: Intensity) =>
|
||||
({ theme }: { theme: Theme }) => {
|
||||
const t = theme.tokens;
|
||||
const tint =
|
||||
intensity === 'strong'
|
||||
? t.glass.tintStrong
|
||||
: intensity === 'subtle'
|
||||
? t.glass.tintSubtle
|
||||
: t.glass.tint;
|
||||
const blur = intensity === 'strong' ? t.glass.blurStrong : t.glass.blur;
|
||||
return {
|
||||
backgroundColor: tint,
|
||||
backdropFilter: blur,
|
||||
WebkitBackdropFilter: blur,
|
||||
border: '1px solid',
|
||||
borderColor: t.glass.border,
|
||||
boxShadow: intensity === 'strong' ? t.elevation.md : t.elevation.sm,
|
||||
borderRadius: t.radius.md,
|
||||
position: 'relative' as const,
|
||||
// Inset top highlight — the "glass lip"
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: `inset 0 1px 0 ${t.glass.highlight}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
export const GlassCard = styled(MuiCard, { shouldForwardProp: forwardable })<GlassOptions>(
|
||||
({ theme, ...options }) => glassSx(theme, options),
|
||||
);
|
||||
|
||||
interface GlassProps {
|
||||
intensity?: Intensity;
|
||||
}
|
||||
export const GlassPaper = styled(MuiPaper, { shouldForwardProp: forwardable })<GlassOptions>(
|
||||
({ theme, ...options }) => glassSx(theme, options),
|
||||
);
|
||||
|
||||
export const GlassCard = styled(MuiCard, {
|
||||
shouldForwardProp: (prop) => prop !== 'intensity',
|
||||
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
|
||||
|
||||
export const GlassPaper = styled(MuiPaper, {
|
||||
shouldForwardProp: (prop) => prop !== 'intensity',
|
||||
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
|
||||
|
||||
export const GlassSurface = styled(Box, {
|
||||
shouldForwardProp: (prop) => prop !== 'intensity',
|
||||
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
|
||||
|
||||
export type { Intensity as GlassIntensity };
|
||||
export const GlassSurface = styled(Box, { shouldForwardProp: forwardable })<GlassOptions>(
|
||||
({ theme, ...options }) => glassSx(theme, options),
|
||||
);
|
||||
@@ -1,36 +1,42 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
|
||||
const StyledAppBar = styled(AppBar, {
|
||||
shouldForwardProp: (prop) => prop !== 'scrolled',
|
||||
})<{ scrolled: boolean }>(({ theme, scrolled }) => {
|
||||
const t = theme.tokens;
|
||||
return {
|
||||
backgroundColor: scrolled
|
||||
? alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.85 : 0.72)
|
||||
: alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.55 : 0.4),
|
||||
backdropFilter: t.glass.blurStrong,
|
||||
WebkitBackdropFilter: t.glass.blurStrong,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: scrolled ? t.glass.border : 'transparent',
|
||||
boxShadow: scrolled ? t.elevation.sm : 'none',
|
||||
color: theme.palette.text.primary,
|
||||
transition: 'background-color 200ms ease, box-shadow 200ms ease, border-color 200ms ease',
|
||||
};
|
||||
});
|
||||
import { GlassSurface } from './Glass';
|
||||
|
||||
export interface GlassAppBarProps {
|
||||
/** Primary row content — brand, search, account. */
|
||||
children: ReactNode;
|
||||
/** Optional second row inside the same pane (the category scroller). */
|
||||
secondary?: ReactNode;
|
||||
threshold?: number;
|
||||
/** Extra styles merged onto the inner Toolbar — e.g. to constrain its content
|
||||
* to the page-content column so the bar aligns with the main content edges. */
|
||||
toolbarSx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const GlassAppBar = ({ children, threshold = 12, toolbarSx }: GlassAppBarProps) => {
|
||||
/**
|
||||
* The app's navigation pane.
|
||||
*
|
||||
* On desktop it is *detached*: inset from the viewport edges and rounded, so
|
||||
* page content scrolls visibly underneath it. That gap is the point — a
|
||||
* refracting rim only reads as glass when there is moving content passing
|
||||
* behind it, and a bar welded to the top edge never gets any.
|
||||
*
|
||||
* On phones it stays full-bleed and square at `top: 0`, keeping the existing
|
||||
* mobile geometry (and the 56/64px row heights the sticky offsets elsewhere
|
||||
* depend on) untouched.
|
||||
*
|
||||
* Scrolling thickens the pane and tightens the rows rather than hiding the
|
||||
* category row — categories are the primary browse axis and shouldn't
|
||||
* disappear on scroll.
|
||||
*/
|
||||
export const GlassAppBar = ({
|
||||
children,
|
||||
secondary,
|
||||
threshold = 12,
|
||||
toolbarSx,
|
||||
}: GlassAppBarProps) => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,17 +47,63 @@ export const GlassAppBar = ({ children, threshold = 12, toolbarSx }: GlassAppBar
|
||||
}, [threshold]);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="sticky" elevation={0} scrolled={scrolled}>
|
||||
<Toolbar
|
||||
sx={
|
||||
[
|
||||
{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } },
|
||||
toolbarSx || {},
|
||||
] as SxProps<Theme>
|
||||
}
|
||||
<Box
|
||||
component="header"
|
||||
sx={(theme) => ({
|
||||
position: 'sticky',
|
||||
top: { xs: 0, md: theme.spacing(1.5) },
|
||||
zIndex: theme.zIndex.appBar,
|
||||
px: { xs: 0, md: 1.5 },
|
||||
pt: { xs: 0, md: 1.5 },
|
||||
})}
|
||||
>
|
||||
<GlassSurface
|
||||
tier="chrome"
|
||||
sx={(theme) => ({
|
||||
borderRadius: { xs: 0, md: `${theme.tokens.radius.xl}px` },
|
||||
borderInline: { xs: 'none', md: '1px solid' },
|
||||
borderTop: { xs: 'none', md: '1px solid' },
|
||||
borderColor: theme.tokens.glass.borderStrong,
|
||||
boxShadow: scrolled ? theme.tokens.elevation.lg : theme.tokens.elevation.md,
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Toolbar>
|
||||
</StyledAppBar>
|
||||
<Toolbar
|
||||
disableGutters
|
||||
sx={
|
||||
[
|
||||
{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: {
|
||||
xs: 56,
|
||||
sm: 64,
|
||||
md: scrolled ? 56 : 64,
|
||||
},
|
||||
px: { xs: 1, sm: 4, md: 6 },
|
||||
transition: (theme: Theme) =>
|
||||
`min-height ${theme.tokens.motion.base} ${theme.tokens.motion.ease}`,
|
||||
},
|
||||
toolbarSx || {},
|
||||
] as SxProps<Theme>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Toolbar>
|
||||
|
||||
{secondary && (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
borderTop: '1px solid',
|
||||
borderColor: theme.tokens.glass.border,
|
||||
})}
|
||||
>
|
||||
{secondary}
|
||||
</Box>
|
||||
)}
|
||||
</GlassSurface>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The SVG filter definitions behind the liquid-glass look, mounted exactly once
|
||||
* (from `AppProviders`) so `url(#…)` resolves anywhere in the app.
|
||||
*
|
||||
* Why these are static: refraction reads as *liquid* because the backdrop moves
|
||||
* behind a fixed displacement map — not because the map itself animates.
|
||||
* Animating `baseFrequency` would re-rasterise the turbulence every frame; a
|
||||
* static seed over the drifting aurora/canvas backdrop costs one raster pass
|
||||
* and looks the same.
|
||||
*
|
||||
* These are consumed through `theme.tokens.glass.rimFilter` /
|
||||
* `rimFilterChroma`, and only ever on a thin masked ring (see `Glass.tsx`) —
|
||||
* never over a whole pane, which would be Chrome-only and far too expensive on
|
||||
* a grid of cards.
|
||||
*/
|
||||
|
||||
/** Turbulence shared by both rim filters: soft, lens-like lobes rather than sand. */
|
||||
const Turbulence = () => (
|
||||
<>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.008 0.012"
|
||||
numOctaves={2}
|
||||
seed={7}
|
||||
result="noise"
|
||||
/>
|
||||
<feGaussianBlur in="noise" stdDeviation="1.2" result="soft" />
|
||||
</>
|
||||
);
|
||||
|
||||
/** Isolates one channel of the source so it can be displaced independently. */
|
||||
const channelMatrix = (channel: 'r' | 'g' | 'b') =>
|
||||
({
|
||||
r: '1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0',
|
||||
g: '0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0',
|
||||
b: '0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0',
|
||||
})[channel];
|
||||
|
||||
export const GlassDefs = () => (
|
||||
<svg
|
||||
width="0"
|
||||
height="0"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<defs>
|
||||
{/* Monochrome refraction — the workhorse, used on the `panel` tier. */}
|
||||
<filter
|
||||
id="hfr-rim"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<Turbulence />
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="soft"
|
||||
scale={11}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
result="disp"
|
||||
/>
|
||||
{/* Displacement near an edge pulls in samples from outside the backdrop
|
||||
image, which come back as transparent black and paint a dark fringe
|
||||
along the rim. Compositing the displaced result *over* the original
|
||||
backdrop fills those gaps with the undisplaced pixels instead. */}
|
||||
<feComposite in="disp" in2="SourceGraphic" operator="over" />
|
||||
</filter>
|
||||
|
||||
{/* Chromatic refraction — R/G/B displaced by different amounts, then
|
||||
screened back together, giving the prismatic edge real glass has.
|
||||
Three displacement passes, so `chrome` tier only. */}
|
||||
<filter
|
||||
id="hfr-rim-chroma"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<Turbulence />
|
||||
<feColorMatrix
|
||||
in="SourceGraphic"
|
||||
type="matrix"
|
||||
values={channelMatrix('r')}
|
||||
result="rOnly"
|
||||
/>
|
||||
<feDisplacementMap
|
||||
in="rOnly"
|
||||
in2="soft"
|
||||
scale={14}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
result="rDisp"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceGraphic"
|
||||
type="matrix"
|
||||
values={channelMatrix('g')}
|
||||
result="gOnly"
|
||||
/>
|
||||
<feDisplacementMap
|
||||
in="gOnly"
|
||||
in2="soft"
|
||||
scale={11}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
result="gDisp"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceGraphic"
|
||||
type="matrix"
|
||||
values={channelMatrix('b')}
|
||||
result="bOnly"
|
||||
/>
|
||||
<feDisplacementMap
|
||||
in="bOnly"
|
||||
in2="soft"
|
||||
scale={8}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
result="bDisp"
|
||||
/>
|
||||
<feBlend in="rDisp" in2="gDisp" mode="screen" result="rg" />
|
||||
<feBlend in="rg" in2="bDisp" mode="screen" result="chroma" />
|
||||
{/* Same edge-fringe patch as #hfr-rim. */}
|
||||
<feComposite in="chroma" in2="SourceGraphic" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Fine grain, as a self-contained data URI rather than a `filter: url()` chain —
|
||||
* it tiles as a plain background-image, so it costs nothing per frame.
|
||||
* Layered at low opacity over the aurora it kills gradient banding and gives
|
||||
* the rim refraction something fine-grained to bend.
|
||||
*/
|
||||
export const GRAIN_DATA_URI =
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\")";
|
||||
@@ -1,46 +0,0 @@
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export interface HeroBackdropProps {
|
||||
variant?: 'mesh' | 'aurora' | 'minimal';
|
||||
}
|
||||
|
||||
const Root = styled(Box, {
|
||||
shouldForwardProp: (prop) => prop !== 'variant',
|
||||
})<{ variant: NonNullable<HeroBackdropProps['variant']> }>(({ theme, variant }) => {
|
||||
const { palette } = theme;
|
||||
const p = palette.primary.main;
|
||||
const s = palette.secondary.main;
|
||||
|
||||
const meshLayers = [
|
||||
`radial-gradient(ellipse 60% 40% at 20% 10%, ${alpha(p, 0.35)} 0%, transparent 60%)`,
|
||||
`radial-gradient(ellipse 50% 50% at 85% 25%, ${alpha(s, 0.28)} 0%, transparent 65%)`,
|
||||
`radial-gradient(ellipse 70% 45% at 50% 95%, ${alpha(p, 0.18)} 0%, transparent 60%)`,
|
||||
];
|
||||
|
||||
const auroraLayers = [
|
||||
`conic-gradient(from 210deg at 50% -10%, ${alpha(p, 0.35)}, ${alpha(s, 0.25)}, ${alpha(p, 0.1)}, ${alpha(p, 0.35)})`,
|
||||
];
|
||||
|
||||
const minimalLayers = [`linear-gradient(180deg, ${alpha(p, 0.12)} 0%, transparent 60%)`];
|
||||
|
||||
const backgroundImage =
|
||||
variant === 'aurora'
|
||||
? auroraLayers.join(', ')
|
||||
: variant === 'minimal'
|
||||
? minimalLayers.join(', ')
|
||||
: meshLayers.join(', ');
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage,
|
||||
backgroundColor: palette.background.default,
|
||||
filter: 'saturate(120%)',
|
||||
// Soft mask so the backdrop fades into the page content
|
||||
maskImage: 'linear-gradient(180deg, #000 0%, #000 55%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(180deg, #000 0%, #000 55%, transparent 100%)',
|
||||
};
|
||||
});
|
||||
|
||||
export const HeroBackdrop = ({ variant = 'mesh' }: HeroBackdropProps) => <Root variant={variant} />;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
import { Box, ButtonBase, IconButton, Modal, Typography, alpha, useTheme } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
@@ -294,7 +295,7 @@ const HeroGallery: React.FC<HeroGalleryProps> = ({ images, alt, badge, enableLig
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(8px)',
|
||||
backdropFilter: (theme: Theme) => theme.tokens.glass.blurCard,
|
||||
}}
|
||||
slotProps={{
|
||||
backdrop: {
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ReviewRow = ({ review, showFlavor = true, compact }: ReviewRowProps
|
||||
style={{ textDecoration: 'none', color: 'inherit', display: 'block' }}
|
||||
>
|
||||
<GlassCard
|
||||
tier="card"
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { CSSObject, Theme } from '@mui/material/styles';
|
||||
|
||||
/** How much the pane occludes what is behind it. */
|
||||
type Intensity = 'subtle' | 'default' | 'strong';
|
||||
|
||||
/**
|
||||
* What kind of glass this is — the axis that governs *cost*, not just looks.
|
||||
*
|
||||
* - `card` — grid cards and list rows. One cheap backdrop layer, CSS-only rim.
|
||||
* A category page renders 40+ of these, so it never gets a second
|
||||
* compositing layer.
|
||||
* - `panel` — hero cards, section panels, forms. Refracting rim (monochrome).
|
||||
* - `chrome` — nav, dialogs, menus, anything floating over live content.
|
||||
* Refracting rim with chromatic split; the most expensive tier,
|
||||
* reserved for the handful of panes that are always on screen.
|
||||
*/
|
||||
type Tier = 'card' | 'panel' | 'chrome';
|
||||
|
||||
export interface GlassOptions {
|
||||
intensity?: Intensity;
|
||||
tier?: Tier;
|
||||
/** Adds the hover lift used by clickable cards and rows. */
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
const tintFor = (theme: Theme, intensity: Intensity, tier: Tier) => {
|
||||
const g = theme.tokens.glass;
|
||||
if (tier === 'chrome') return g.tintChrome;
|
||||
if (intensity === 'strong') return g.tintStrong;
|
||||
if (intensity === 'subtle') return g.tintSubtle;
|
||||
return g.tint;
|
||||
};
|
||||
|
||||
const blurFor = (theme: Theme, intensity: Intensity, tier: Tier) => {
|
||||
const g = theme.tokens.glass;
|
||||
if (tier === 'card') return g.blurCard;
|
||||
if (tier === 'chrome' || intensity === 'strong') return g.blurStrong;
|
||||
return g.blur;
|
||||
};
|
||||
|
||||
// Emitted with an explicit unit: a bare number for `borderRadius` is treated by
|
||||
// MUI's `sx` system as a multiplier of `theme.shape.borderRadius`, so spreading
|
||||
// this object into an `sx` prop would turn radius 20 into 200px.
|
||||
const radiusFor = (theme: Theme, tier: Tier) =>
|
||||
`${tier === 'card' ? theme.tokens.radius.md : theme.tokens.radius.lg}px`;
|
||||
|
||||
/**
|
||||
* The full three-layer pane. Exported so the call sites that hand-roll
|
||||
* `backdropFilter: theme.tokens.glass.blur` in an `sx` prop share one
|
||||
* definition instead of drifting apart.
|
||||
*
|
||||
* Layer 1 (element) — tint + backdrop blur.
|
||||
* Layer 2 (::before) — specular sheen and the paired inset light/dark edges
|
||||
* that make the pane read as having thickness.
|
||||
* Layer 3 (::after) — the rim ring. A transparent border plus a
|
||||
* border-box/padding-box XOR mask confines it to a ring;
|
||||
* on Chromium that ring additionally refracts the backdrop
|
||||
* through `feTurbulence`+`feDisplacementMap`. Elsewhere it
|
||||
* stays a light-catching gradient hairline, which is a
|
||||
* designed fallback rather than a broken one.
|
||||
*/
|
||||
export const glassSx = (theme: Theme, options: GlassOptions = {}): CSSObject => {
|
||||
const { intensity = 'default', tier = 'panel', interactive = false } = options;
|
||||
const t = theme.tokens;
|
||||
const blur = blurFor(theme, intensity, tier);
|
||||
const rim = tier === 'chrome' ? t.glass.rimFilterChroma : t.glass.rimFilter;
|
||||
const ringMask = 'linear-gradient(#000 0 0) border-box, linear-gradient(#000 0 0) padding-box';
|
||||
|
||||
return {
|
||||
position: 'relative',
|
||||
backgroundColor: tintFor(theme, intensity, tier),
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: blur,
|
||||
WebkitBackdropFilter: blur,
|
||||
border: '1px solid',
|
||||
borderColor: tier === 'chrome' ? t.glass.borderStrong : t.glass.border,
|
||||
borderRadius: radiusFor(theme, tier),
|
||||
boxShadow: tier === 'card' ? t.elevation.sm : t.elevation.md,
|
||||
transition: `transform ${t.motion.base} ${t.motion.ease}, box-shadow ${t.motion.base} ${t.motion.ease}, border-color ${t.motion.base} ${t.motion.ease}, background-color ${t.motion.base} ${t.motion.ease}`,
|
||||
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
background: t.glass.specular,
|
||||
boxShadow: t.glass.shadowInner,
|
||||
// Keep the sheen under the pane's own content.
|
||||
zIndex: 0,
|
||||
},
|
||||
|
||||
// The ring is deliberately almost colourless. Its job is to *bend* the
|
||||
// backdrop, not to paint a bright band — a filled ring just reads as a
|
||||
// thick white border. The crisp edge catch comes from `shadowInner` above.
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
border: `${tier === 'card' ? 1 : t.glass.rimWidth}px solid transparent`,
|
||||
background: `linear-gradient(140deg, ${t.glass.rimSheen} 0%, transparent 40%, transparent 60%, ${t.glass.rimSheen} 100%)`,
|
||||
mask: ringMask,
|
||||
maskComposite: 'exclude',
|
||||
WebkitMask: ringMask,
|
||||
WebkitMaskComposite: 'xor',
|
||||
},
|
||||
|
||||
// Chromium only: the ring additionally bends the backdrop behind it.
|
||||
...(tier === 'card'
|
||||
? {}
|
||||
: {
|
||||
'[data-glass="refract"] &::after': {
|
||||
backdropFilter: rim,
|
||||
WebkitBackdropFilter: rim,
|
||||
},
|
||||
}),
|
||||
|
||||
// prefers-reduced-transparency: drop translucency entirely rather than
|
||||
// shipping a half-legible pane.
|
||||
'[data-glass="flat"] &': {
|
||||
backdropFilter: 'none',
|
||||
WebkitBackdropFilter: 'none',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
'[data-glass="flat"] &::before': { background: 'none' },
|
||||
|
||||
...(interactive
|
||||
? {
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: t.elevation.lg,
|
||||
borderColor: t.glass.borderStrong,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export type { Intensity as GlassIntensity, Tier as GlassTier };
|
||||
@@ -1,8 +1,10 @@
|
||||
export { GlassCard, GlassPaper, GlassSurface, type GlassIntensity } from './Glass';
|
||||
export { GlassCard, GlassPaper, GlassSurface } from './Glass';
|
||||
export { glassSx, type GlassIntensity, type GlassTier, type GlassOptions } from './glass-style';
|
||||
export { GlassDefs, GRAIN_DATA_URI } from './GlassDefs';
|
||||
export { AppBackdrop, type AppBackdropProps } from './AppBackdrop';
|
||||
export { GlassAppBar, type GlassAppBarProps } from './GlassAppBar';
|
||||
export { PageShell, type PageShellProps } from './PageShell';
|
||||
export { SectionHeader, type SectionHeaderProps } from './SectionHeader';
|
||||
export { HeroBackdrop, type HeroBackdropProps } from './HeroBackdrop';
|
||||
export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow';
|
||||
export { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard';
|
||||
export { FlavorThumb, type FlavorThumbProps } from './FlavorThumb';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||
import { BackdropArtContext, type BackdropArtValue } from './backdrop-art-context';
|
||||
|
||||
/**
|
||||
* Lets a route hand procedural art to the app-wide backdrop.
|
||||
*
|
||||
* The backdrop is mounted once, fixed behind everything, so pages can't render
|
||||
* into it directly — they publish here instead and `AppBackdrop` picks it up.
|
||||
* That keeps the art *behind* the glass panes rather than boxed inside a hero
|
||||
* strip, which is the whole reason the refraction reads as glass.
|
||||
*/
|
||||
export const BackdropArtProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [art, setArtState] = useState<ReactNode>(null);
|
||||
const setArt = useCallback((next: ReactNode) => setArtState(next), []);
|
||||
const value = useMemo<BackdropArtValue>(() => ({ art, setArt }), [art, setArt]);
|
||||
|
||||
return <BackdropArtContext.Provider value={value}>{children}</BackdropArtContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext, type ReactNode } from 'react';
|
||||
|
||||
export interface BackdropArtValue {
|
||||
art: ReactNode;
|
||||
/**
|
||||
* Publishes (or clears, with `null`) the canvas art layer of the global
|
||||
* backdrop. Pages call this through `useBackdropArt` rather than directly.
|
||||
*/
|
||||
setArt: (art: ReactNode) => void;
|
||||
}
|
||||
|
||||
export const BackdropArtContext = createContext<BackdropArtValue | null>(null);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { BackdropArtContext } from '../contexts/backdrop-art-context';
|
||||
|
||||
/** Reads the currently published backdrop art. Used by `AppBackdrop` only. */
|
||||
export const useBackdropArtValue = () => useContext(BackdropArtContext)?.art ?? null;
|
||||
|
||||
/**
|
||||
* Publishes canvas art into the global backdrop for as long as this component
|
||||
* is mounted, then clears it. Pass a memoised element — an inline element
|
||||
* recreated every render would re-publish (and so re-seed the canvas) on each
|
||||
* one, hence the explicit `deps`.
|
||||
*/
|
||||
export const useBackdropArt = (art: ReactNode, deps: React.DependencyList = []) => {
|
||||
const ctx = useContext(BackdropArtContext);
|
||||
const setArt = ctx?.setArt;
|
||||
|
||||
useEffect(() => {
|
||||
if (!setArt) return;
|
||||
setArt(art);
|
||||
return () => setArt(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setArt, ...deps]);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useGlassCapability } from './useGlassCapability';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const mockMatchMedia = (matches: Record<string, boolean>) => {
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: !!matches[query],
|
||||
media: query,
|
||||
addEventListener: (_: string, l: Listener) => {
|
||||
if (!listeners.has(query)) listeners.set(query, new Set());
|
||||
listeners.get(query)!.add(l);
|
||||
},
|
||||
removeEventListener: (_: string, l: Listener) => listeners.get(query)?.delete(l),
|
||||
}));
|
||||
};
|
||||
|
||||
const setChromium = (isChromium: boolean) => {
|
||||
Object.defineProperty(navigator, 'userAgentData', {
|
||||
configurable: true,
|
||||
value: isChromium ? { brands: [{ brand: 'Chromium' }, { brand: 'Not?A_Brand' }] } : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
describe('useGlassCapability', () => {
|
||||
const realSupports = CSS.supports;
|
||||
|
||||
beforeEach(() => {
|
||||
CSS.supports = vi.fn().mockReturnValue(true);
|
||||
mockMatchMedia({});
|
||||
setChromium(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
CSS.supports = realSupports;
|
||||
delete document.documentElement.dataset.glass;
|
||||
});
|
||||
|
||||
it('resolves to the refract tier on a Chromium engine and stamps <html>', () => {
|
||||
const { result } = renderHook(() => useGlassCapability());
|
||||
expect(result.current.tier).toBe('refract');
|
||||
expect(result.current.refract).toBe(true);
|
||||
expect(document.documentElement.dataset.glass).toBe('refract');
|
||||
});
|
||||
|
||||
it('falls back to blur off-Chromium even though the value parses', () => {
|
||||
// Firefox and Safari both accept `url()` in a filter list and then paint
|
||||
// nothing, so parsing support alone must not promote a page to refract.
|
||||
setChromium(false);
|
||||
const { result } = renderHook(() => useGlassCapability());
|
||||
expect(result.current.tier).toBe('blur');
|
||||
expect(result.current.refract).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to blur when mask-composite is unavailable', () => {
|
||||
CSS.supports = vi
|
||||
.fn()
|
||||
.mockImplementation((prop: string) => !prop.toLowerCase().includes('mask-composite'));
|
||||
const { result } = renderHook(() => useGlassCapability());
|
||||
expect(result.current.tier).toBe('blur');
|
||||
});
|
||||
|
||||
it('drops to the flat tier under prefers-reduced-transparency', () => {
|
||||
mockMatchMedia({ '(prefers-reduced-transparency: reduce)': true });
|
||||
const { result } = renderHook(() => useGlassCapability());
|
||||
expect(result.current.tier).toBe('flat');
|
||||
expect(document.documentElement.dataset.glass).toBe('flat');
|
||||
});
|
||||
|
||||
it('reports reduced motion so the backdrop can halt its animations', () => {
|
||||
mockMatchMedia({ '(prefers-reduced-motion: reduce)': true });
|
||||
const { result } = renderHook(() => useGlassCapability());
|
||||
expect(result.current.reducedMotion).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type GlassTier = 'refract' | 'blur' | 'flat';
|
||||
|
||||
export interface GlassCapability {
|
||||
/** Which visual tier the document is currently rendering at. */
|
||||
tier: GlassTier;
|
||||
/** True when SVG rim refraction is live. */
|
||||
refract: boolean;
|
||||
/** True when the user asked for reduced motion — aurora + canvas must halt. */
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
const mq = (query: string): MediaQueryList | null => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null;
|
||||
try {
|
||||
return window.matchMedia(query);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Can this engine actually render `backdrop-filter: url(#svg-filter)`?
|
||||
*
|
||||
* `CSS.supports` alone is not enough: `url()` is syntactically valid in a
|
||||
* filter-function-list, so Firefox and Safari both parse it happily and then
|
||||
* paint nothing — a silent failure that would leave their rims invisible
|
||||
* rather than gracefully flat. Chromium is currently the only engine that
|
||||
* renders it, so we gate on a Chromium signal too. `userAgentData` is a real
|
||||
* feature-detectable API rather than UA-string sniffing, and when it is absent
|
||||
* we simply fall back a tier — which is a designed look, not a broken one.
|
||||
*/
|
||||
const supportsRefraction = (): boolean => {
|
||||
if (typeof window === 'undefined' || typeof CSS === 'undefined' || !CSS.supports) return false;
|
||||
const parses =
|
||||
CSS.supports('backdrop-filter', 'url(#x)') ||
|
||||
CSS.supports('-webkit-backdrop-filter', 'url(#x)');
|
||||
if (!parses) return false;
|
||||
// mask-composite carries the rim ring; without it there is no ring to refract.
|
||||
const canMask =
|
||||
CSS.supports('mask-composite', 'exclude') || CSS.supports('-webkit-mask-composite', 'xor');
|
||||
if (!canMask) return false;
|
||||
const brands = (navigator as Navigator & { userAgentData?: { brands?: { brand: string }[] } })
|
||||
.userAgentData?.brands;
|
||||
return Array.isArray(brands) && brands.some((b) => /Chromium/i.test(b.brand));
|
||||
};
|
||||
|
||||
const resolveTier = (): GlassTier => {
|
||||
if (mq('(prefers-reduced-transparency: reduce)')?.matches) return 'flat';
|
||||
return supportsRefraction() ? 'refract' : 'blur';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the glass tier and stamps it on `<html data-glass>`, which is what
|
||||
* every refraction rule in `Glass.tsx` keys off. Call once, high in the tree.
|
||||
*/
|
||||
export const useGlassCapability = (): GlassCapability => {
|
||||
const [state, setState] = useState<GlassCapability>(() => ({
|
||||
tier: 'blur',
|
||||
refract: false,
|
||||
reducedMotion: false,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const transparency = mq('(prefers-reduced-transparency: reduce)');
|
||||
const motion = mq('(prefers-reduced-motion: reduce)');
|
||||
|
||||
const apply = () => {
|
||||
const tier = resolveTier();
|
||||
const reducedMotion = !!motion?.matches;
|
||||
document.documentElement.dataset.glass = tier;
|
||||
setState((prev) =>
|
||||
prev.tier === tier && prev.reducedMotion === reducedMotion
|
||||
? prev
|
||||
: { tier, refract: tier === 'refract', reducedMotion },
|
||||
);
|
||||
};
|
||||
|
||||
apply();
|
||||
transparency?.addEventListener?.('change', apply);
|
||||
motion?.addEventListener?.('change', apply);
|
||||
return () => {
|
||||
transparency?.removeEventListener?.('change', apply);
|
||||
motion?.removeEventListener?.('change', apply);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-family: 'Plus Jakarta Sans Variable', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
// Self-hosted rather than pulled from Google Fonts: the theme has asked for
|
||||
// this face all along without ever loading it, and a German-facing site
|
||||
// shouldn't hand visitor IPs to a third-party font CDN to fix that.
|
||||
import '@fontsource-variable/plus-jakarta-sans';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import { AppProviders } from './app/providers';
|
||||
|
||||
@@ -4,14 +4,14 @@ import { Link } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
|
||||
import { PageShell, GlassCard, SectionHeader } from '../components/ui';
|
||||
|
||||
const About: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
useTitle(t('nav.about'));
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
|
||||
@@ -53,7 +53,6 @@ import RatingBadge from '../components/RatingBadge';
|
||||
import ReactionBar from '../components/ReactionBar';
|
||||
import {
|
||||
PageShell,
|
||||
HeroBackdrop,
|
||||
SectionHeader,
|
||||
GlassCard,
|
||||
FlavorThumb,
|
||||
@@ -160,7 +159,7 @@ const SuggestedFollows: React.FC = () => {
|
||||
const FeedSkeleton: React.FC = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<GlassCard key={i} intensity="default">
|
||||
<GlassCard key={i} intensity="default" tier="card">
|
||||
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
@@ -283,7 +282,7 @@ const CommunityFeed: React.FC = () => {
|
||||
const expanded = !!expandedReplies[rating.id];
|
||||
const latestReply = rating.replies[rating.replies.length - 1];
|
||||
return (
|
||||
<GlassCard key={rating.id} intensity="default">
|
||||
<GlassCard key={rating.id} intensity="default" tier="card">
|
||||
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
@@ -489,7 +488,7 @@ const CommunityFeed: React.FC = () => {
|
||||
const renderActivityCard = (item: ActivityItem) => {
|
||||
const isMilestone = item.kind === 'milestone';
|
||||
return (
|
||||
<GlassCard key={item.id} intensity="default">
|
||||
<GlassCard key={item.id} intensity="default" tier="card">
|
||||
<CardContent sx={{ p: { xs: 2, sm: 2.5 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
@@ -545,7 +544,7 @@ const CommunityFeed: React.FC = () => {
|
||||
: t('community.subtitle');
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<PageShell>
|
||||
<BackButton />
|
||||
|
||||
<SectionHeader title={t('community.title')} subtitle={subtitle} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconButton,
|
||||
Skeleton,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -28,11 +29,14 @@ import {
|
||||
GlassCard,
|
||||
PageShell,
|
||||
SectionHeader,
|
||||
HeroBackdrop,
|
||||
FlavorCard,
|
||||
FlavorGrid,
|
||||
EmptyState,
|
||||
glassSx,
|
||||
} from '../components/ui';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { GenerativeFlowBanner } from '../components/banners';
|
||||
import { useBackdropArt } from '../hooks/useBackdropArt';
|
||||
import MainPageRecommendations from './MainPageRecommendations';
|
||||
import { CategoryBrowseGrid } from '../components/home/DesktopHomeSections';
|
||||
import { useCategories } from '../api/queries/useCategories';
|
||||
@@ -59,6 +63,7 @@ const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => (
|
||||
const HeroCarouselSkeleton: React.FC = () => (
|
||||
<GlassCard
|
||||
intensity="strong"
|
||||
tier="panel"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
@@ -86,6 +91,8 @@ const HeroCarouselSkeleton: React.FC = () => (
|
||||
|
||||
const MainPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const location = useLocation();
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
// Auto-advance stops for good once the user takes manual control; hover only
|
||||
@@ -119,6 +126,22 @@ const MainPage: React.FC = () => {
|
||||
|
||||
useTitle(query ? `Search: ${query}` : 'Holy Flavors Archive');
|
||||
|
||||
// Home is the one route that earns full-bleed procedural art behind the
|
||||
// glass. Memoised so the canvas isn't re-seeded on every render.
|
||||
const backdropArt = useMemo(
|
||||
() => (
|
||||
<GenerativeFlowBanner
|
||||
username="holy-flavors"
|
||||
palette={[]}
|
||||
ratingsCount={0}
|
||||
followersCount={0}
|
||||
settings={{ blobCount: 6, flowSpeed: 0.005, vibrancy: 0.9, blur: 80 }}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
useBackdropArt(query ? null : backdropArt, [query, backdropArt]);
|
||||
|
||||
const handlePrev = () => {
|
||||
if (topFlavors.length === 0) return;
|
||||
setAutoPlay(false);
|
||||
@@ -187,7 +210,7 @@ const MainPage: React.FC = () => {
|
||||
|
||||
// --- HOME VIEW ---
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant={isLoggedIn ? 'minimal' : 'mesh'} />}>
|
||||
<PageShell>
|
||||
{/* Hero */}
|
||||
{isLoggedIn ? (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
@@ -218,6 +241,7 @@ const MainPage: React.FC = () => {
|
||||
) : (
|
||||
<GlassCard
|
||||
intensity="strong"
|
||||
tier="panel"
|
||||
sx={{
|
||||
p: { xs: 4, md: 8 },
|
||||
textAlign: 'center',
|
||||
@@ -309,6 +333,7 @@ const MainPage: React.FC = () => {
|
||||
<>
|
||||
<GlassCard
|
||||
intensity="strong"
|
||||
tier="panel"
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={t('home.hallOfFame')}
|
||||
@@ -336,9 +361,8 @@ const MainPage: React.FC = () => {
|
||||
left: 8,
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': { bgcolor: 'background.paper' },
|
||||
...glassSx(theme, { intensity: 'strong', tier: 'card' }),
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
@@ -353,20 +377,25 @@ const MainPage: React.FC = () => {
|
||||
right: 8,
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': { bgcolor: 'background.paper' },
|
||||
...glassSx(theme, { intensity: 'strong', tier: 'card' }),
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
{/* On desktop the artwork runs the full width of the pane and the
|
||||
text column floats on top of it as glass — that overlap is the
|
||||
point of the whole treatment. Phones keep the original stacked
|
||||
layout: a 16/9 image with the copy below it. */}
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/flavor/${currentTop.id}`}
|
||||
sx={{
|
||||
width: { xs: '100%', md: '45%' },
|
||||
position: { xs: 'relative', md: 'absolute' },
|
||||
inset: { md: 0 },
|
||||
width: '100%',
|
||||
height: { md: '100%' },
|
||||
aspectRatio: { xs: '16/9', md: 'auto' },
|
||||
display: 'flex',
|
||||
@@ -375,7 +404,6 @@ const MainPage: React.FC = () => {
|
||||
bgcolor: 'action.hover',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
|
||||
@@ -390,17 +418,36 @@ const MainPage: React.FC = () => {
|
||||
src={currentTop.image_url}
|
||||
alt={currentTop.name}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<CardContent
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: { xs: 3, sm: 4, md: 6 },
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flex: { xs: 1, md: '0 0 auto' },
|
||||
width: { xs: '100%', md: '50%' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
...glassSx(theme, { intensity: 'strong', tier: 'panel' }),
|
||||
// Margins come after the glass spread so they win: `ml: auto`
|
||||
// is what pushes the column to the right half of the artwork.
|
||||
my: { md: 2.5 },
|
||||
mr: { md: 2.5 },
|
||||
ml: { md: 'auto' },
|
||||
p: { xs: 3, sm: 4, md: 5 },
|
||||
// Occlusion has to beat the artwork here — these product
|
||||
// shots are high-contrast and unpredictable, and a standard
|
||||
// glass tint leaves the title unreadable over the bright ones.
|
||||
backgroundColor: {
|
||||
xs: 'transparent',
|
||||
md: alpha(theme.palette.background.paper, isLight ? 0.78 : 0.72),
|
||||
},
|
||||
backdropFilter: { xs: 'none', md: theme.tokens.glass.blurStrong },
|
||||
border: { xs: 'none', md: '1px solid' },
|
||||
boxShadow: { xs: 'none', md: theme.tokens.elevation.lg },
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
@@ -438,6 +485,7 @@ const MainPage: React.FC = () => {
|
||||
{featuredReview && (
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
tier="card"
|
||||
sx={{ mt: 1, p: { xs: 2, md: 2.5 }, boxShadow: 'none' }}
|
||||
>
|
||||
<Typography
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
|
||||
import { PageShell, GlassCard, SectionHeader } from '../components/ui';
|
||||
|
||||
const Privacy: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -18,7 +18,7 @@ const Privacy: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<PageShell>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
@@ -43,6 +43,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
import RichText from '../components/RichText';
|
||||
import DynamicBanner from '../components/DynamicBanner';
|
||||
import { useBackdropArt } from '../hooks/useBackdropArt';
|
||||
import { formatDate } from '../utils/date';
|
||||
import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../components/ui';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
@@ -155,6 +156,22 @@ const PublicProfile: React.FC = () => {
|
||||
|
||||
useTitle(data ? `${data.username}'s Ratings` : 'Profile');
|
||||
|
||||
// The user's own generative banner becomes the page backdrop. Each profile
|
||||
// therefore looks through glass at art seeded by that profile.
|
||||
const backdropArt = useMemo(
|
||||
() =>
|
||||
data ? (
|
||||
<DynamicBanner
|
||||
username={data.username}
|
||||
palette={palette}
|
||||
ratingsCount={data.ratings.length}
|
||||
followersCount={data.followers_count}
|
||||
/>
|
||||
) : null,
|
||||
[data, palette],
|
||||
);
|
||||
useBackdropArt(backdropArt, [backdropArt]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -184,6 +201,14 @@ const PublicProfile: React.FC = () => {
|
||||
const currentRatings: PublicProfileRating[] =
|
||||
categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]];
|
||||
|
||||
// Tier swatches are fixed brand-ish colours, not theme colours, so pick the
|
||||
// label colour from the swatch's own luminance instead of the palette.
|
||||
const readableOn = (hex: string) => {
|
||||
const c = hex.replace('#', '');
|
||||
const [r, g, b] = [0, 1, 2].map((i) => parseInt(c.slice(i * 2, i * 2 + 2), 16) / 255);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? '#101014' : '#ffffff';
|
||||
};
|
||||
|
||||
const tiers = [
|
||||
{ key: 'S', title: 'S-Tier', color: '#ff7f7f' },
|
||||
{ key: 'A', title: 'A-Tier', color: '#ffbf7f' },
|
||||
@@ -301,16 +326,14 @@ const PublicProfile: React.FC = () => {
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
{/* Profile Header */}
|
||||
{/* Profile Header — the generative banner is no longer boxed into a strip
|
||||
at the top of this card; it now runs behind the entire page (see the
|
||||
useBackdropArt call above) with the profile panel floating on it. What
|
||||
stays here is a short colour band derived from the user's avatar, so
|
||||
the card still reads as theirs. */}
|
||||
<GlassCard intensity="strong" sx={{ overflow: 'hidden' }}>
|
||||
<Box sx={{ height: { xs: 120, sm: 180 }, position: 'relative', overflow: 'hidden' }}>
|
||||
<Box sx={{ height: { xs: 72, sm: 104 }, position: 'relative', overflow: 'hidden' }}>
|
||||
<Box sx={{ position: 'absolute', inset: 0, background: abstractBanner }} />
|
||||
<DynamicBanner
|
||||
username={data.username}
|
||||
palette={palette}
|
||||
ratingsCount={data.ratings.length}
|
||||
followersCount={data.followers_count}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<CardContent
|
||||
@@ -331,7 +354,7 @@ const PublicProfile: React.FC = () => {
|
||||
p: 0.75,
|
||||
borderRadius: '50%',
|
||||
background: `linear-gradient(135deg, ${bannerColors[0]}, ${bannerColors[2]})`,
|
||||
boxShadow: '0 12px 48px rgba(0,0,0,0.25)',
|
||||
boxShadow: theme.tokens.elevation.lg,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
@@ -546,12 +569,21 @@ const PublicProfile: React.FC = () => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: tier.color,
|
||||
color: 'black',
|
||||
// Tier colours are fixed by convention (S red →
|
||||
// D green), so the label colour is derived from the
|
||||
// swatch rather than the theme, and the block gets
|
||||
// the same glass treatment as everything else so it
|
||||
// doesn't read as a sticker pasted on the page.
|
||||
bgcolor: alpha(tier.color, 0.8),
|
||||
backdropFilter: theme.tokens.glass.blurCard,
|
||||
WebkitBackdropFilter: theme.tokens.glass.blurCard,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(tier.color, 0.9),
|
||||
color: readableOn(tier.color),
|
||||
fontWeight: 900,
|
||||
fontSize: '2rem',
|
||||
borderRadius: 0.5,
|
||||
boxShadow: `0 0 20px ${alpha(tier.color, 0.6)}`,
|
||||
borderRadius: `${theme.tokens.radius.sm}px`,
|
||||
boxShadow: `0 0 24px ${alpha(tier.color, 0.45)}`,
|
||||
}}
|
||||
>
|
||||
{tier.key}
|
||||
@@ -734,8 +766,9 @@ const PublicProfile: React.FC = () => {
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
border: '2px solid white',
|
||||
boxShadow: theme.tokens.elevation.sm,
|
||||
border: '2px solid',
|
||||
borderColor: theme.tokens.glass.borderStrong,
|
||||
}}
|
||||
>
|
||||
{comment.author_username.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -272,7 +272,7 @@ const Support: React.FC = () => {
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
|
||||
color: msg.is_admin ? 'white' : 'inherit',
|
||||
color: msg.is_admin ? 'primary.contrastText' : 'inherit',
|
||||
boxShadow: msg.is_admin ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { PageShell, HeroBackdrop, GlassCard } from '../components/ui';
|
||||
import { PageShell, GlassCard } from '../components/ui';
|
||||
|
||||
type VerifyStatus = 'loading' | 'success' | 'error';
|
||||
|
||||
@@ -50,7 +50,7 @@ const VerifyEmail: React.FC = () => {
|
||||
}, [linkValid, username, code, navigate]);
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<PageShell>
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{ p: 6, textAlign: 'center', maxWidth: 520, width: '100%', mx: 'auto' }}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GlassCard } from '../../components/ui';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
@@ -73,7 +73,7 @@ const AdminFlavors: React.FC = () => {
|
||||
{t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })}
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ mb: 4 }}>
|
||||
<GlassCard sx={{ mb: 4 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('admin.mergeFlavors', { defaultValue: 'Merge Flavors' })}
|
||||
@@ -143,7 +143,7 @@ const AdminFlavors: React.FC = () => {
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
<Dialog open={confirmOpen} onClose={() => !isMerging && setConfirmOpen(false)}>
|
||||
<DialogTitle>
|
||||
|
||||
@@ -198,15 +198,16 @@ const AdminJobs = () => {
|
||||
{t('admin.jobLastOutput')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
sx={(theme) => ({
|
||||
p: 1.5,
|
||||
bgcolor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
borderRadius: 2,
|
||||
bgcolor: theme.tokens.terminal.bg,
|
||||
color: theme.tokens.terminal.fg,
|
||||
borderRadius: `${theme.tokens.radius.sm}px`,
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #333',
|
||||
}}
|
||||
border: '1px solid',
|
||||
borderColor: theme.tokens.terminal.border,
|
||||
})}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
|
||||
@@ -353,7 +353,7 @@ const AdminTickets: React.FC = () => {
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
|
||||
color: msg.is_admin ? 'white' : 'inherit',
|
||||
color: msg.is_admin ? 'primary.contrastText' : 'inherit',
|
||||
boxShadow: msg.is_admin ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors';
|
||||
import {
|
||||
PageShell,
|
||||
HeroBackdrop,
|
||||
SectionHeader,
|
||||
FlavorCard,
|
||||
FlavorGrid,
|
||||
@@ -32,7 +31,7 @@ const CategoryFlavors: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="mesh" />}>
|
||||
<PageShell>
|
||||
<BackButton />
|
||||
|
||||
<SectionHeader title={categoryName || t('home.flavorsFallback')} />
|
||||
|
||||
@@ -145,7 +145,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
p: 0.5,
|
||||
borderRadius: '50%',
|
||||
background: `linear-gradient(135deg, ${grad[0]}, ${grad[1]})`,
|
||||
boxShadow: { xs: 'none', sm: '0 12px 48px rgba(0,0,0,0.25)' },
|
||||
boxShadow: { xs: 'none', sm: theme.tokens.elevation.lg },
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
|
||||
@@ -107,7 +107,7 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard>
|
||||
<GlassCard tier="card">
|
||||
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
{isEditing ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
import { Box, CardContent, Chip, Tooltip, Typography } from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import GroupsIcon from '@mui/icons-material/Groups';
|
||||
@@ -31,6 +33,7 @@ const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
tier="card"
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
height: '100%',
|
||||
@@ -48,7 +51,7 @@ const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
|
||||
sx={{
|
||||
position: 'relative',
|
||||
aspectRatio: '1/1',
|
||||
bgcolor: 'background.default',
|
||||
bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.06),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../../api/mutations/useRatingMutations';
|
||||
import { useUpdateReply } from '../../api/mutations/useReplyMutations';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
import { useBackdropArt } from '../../hooks/useBackdropArt';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import CommentIcon from '@mui/icons-material/Comment';
|
||||
@@ -36,7 +37,6 @@ import RatingBadge from '../../components/RatingBadge';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import {
|
||||
PageShell,
|
||||
HeroBackdrop,
|
||||
GlassCard,
|
||||
SectionHeader,
|
||||
EmptyState,
|
||||
@@ -86,6 +86,29 @@ const FlavorDetail: React.FC = () => {
|
||||
|
||||
const galleryImages = useMemo(() => flavor?.image_urls ?? [], [flavor?.image_urls]);
|
||||
|
||||
// The product shot itself becomes the page's backdrop — blown up and blurred
|
||||
// far past recognition, so it reads as coloured light rather than a picture.
|
||||
// It gives every pane on this page something of the actual product to refract.
|
||||
const heroImage = galleryImages[0] ?? flavor?.image_url ?? null;
|
||||
const backdropArt = useMemo(
|
||||
() =>
|
||||
heroImage ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: '-12%',
|
||||
backgroundImage: `url(${JSON.stringify(heroImage)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center 20%',
|
||||
filter: 'blur(90px) saturate(180%)',
|
||||
transform: 'scale(1.15)',
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
[heroImage],
|
||||
);
|
||||
useBackdropArt(backdropArt, [backdropArt]);
|
||||
|
||||
const handleReplySubmit = async (ratingId: number) => {
|
||||
const text = replyInputs[ratingId];
|
||||
if (!text) return;
|
||||
@@ -162,7 +185,7 @@ const FlavorDetail: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<PageShell>
|
||||
<BackButton />
|
||||
|
||||
<Grid container spacing={4}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
import { Box, CardContent, Chip, Skeleton, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import RecommendIcon from '@mui/icons-material/Recommend';
|
||||
import GroupsIcon from '@mui/icons-material/Groups';
|
||||
@@ -36,7 +38,7 @@ const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => {
|
||||
sx={{
|
||||
position: 'relative',
|
||||
aspectRatio: '1/1',
|
||||
bgcolor: 'background.default',
|
||||
bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.06),
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
|
||||
+242
-3
@@ -21,10 +21,27 @@ export interface ThemeTokens {
|
||||
tint: string;
|
||||
tintStrong: string;
|
||||
tintSubtle: string;
|
||||
/** Tint for the floating nav / command pane — sits over live content. */
|
||||
tintChrome: string;
|
||||
border: string;
|
||||
borderStrong: string;
|
||||
highlight: string;
|
||||
blur: string;
|
||||
blurStrong: string;
|
||||
/** Cheaper blur for grid cards; a category page renders 40+ of these. */
|
||||
blurCard: string;
|
||||
/** Thickness in px of the refracting rim ring. */
|
||||
rimWidth: number;
|
||||
/** Very low-alpha sheen painted across the rim ring. */
|
||||
rimSheen: string;
|
||||
/** Filter stack applied to the rim ring only (see GlassDefs). */
|
||||
rimFilter: string;
|
||||
/** Chromatic variant — chrome tier only. */
|
||||
rimFilterChroma: string;
|
||||
/** Diagonal specular sheen laid over the pane body. */
|
||||
specular: string;
|
||||
/** Paired inset light/dark edges that sell pane thickness. */
|
||||
shadowInner: string;
|
||||
};
|
||||
accent: {
|
||||
primary: string;
|
||||
@@ -43,8 +60,23 @@ export interface ThemeTokens {
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
pill: number;
|
||||
};
|
||||
motion: {
|
||||
fast: string;
|
||||
base: string;
|
||||
slow: string;
|
||||
ease: string;
|
||||
/** Slight overshoot — used for panes entering and nav collapse. */
|
||||
spring: string;
|
||||
};
|
||||
/** Monospace log/terminal surfaces (admin job output). */
|
||||
terminal: {
|
||||
bg: string;
|
||||
fg: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CatppuccinTheme =
|
||||
@@ -91,6 +123,23 @@ interface ThemePalette {
|
||||
secondary: PaletteColor;
|
||||
}
|
||||
|
||||
/** sRGB relative luminance of a #rrggbb string, 0 (black) .. 1 (white). */
|
||||
const relativeLuminance = (hex: string): number => {
|
||||
const h = hex.replace('#', '');
|
||||
const full =
|
||||
h.length === 3
|
||||
? h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: h;
|
||||
const channel = (i: number) => {
|
||||
const v = parseInt(full.slice(i * 2, i * 2 + 2), 16) / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
|
||||
};
|
||||
|
||||
export const getTheme = (mode: CatppuccinTheme) => {
|
||||
let palette: ThemePalette;
|
||||
|
||||
@@ -258,6 +307,12 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
|
||||
const isLight = isLightTheme(mode);
|
||||
|
||||
// How dark the page ground is. Near-black themes (t0p_blurryface, t0p_clancy)
|
||||
// have nothing for a blur to gather, so the glass reads as a flat grey film
|
||||
// unless we thin the blur and brighten the rim. `deep` is that signal.
|
||||
const lum = relativeLuminance(palette.base.hex);
|
||||
const deep = !isLight && lum < 0.06;
|
||||
|
||||
const tokens: ThemeTokens = {
|
||||
surface: {
|
||||
base: palette.base.hex,
|
||||
@@ -269,10 +324,21 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
tint: alpha(palette.surface0.hex, isLight ? 0.6 : 0.42),
|
||||
tintStrong: alpha(palette.surface0.hex, isLight ? 0.78 : 0.6),
|
||||
tintSubtle: alpha(palette.surface0.hex, isLight ? 0.42 : 0.28),
|
||||
tintChrome: alpha(palette.base.hex, isLight ? 0.62 : deep ? 0.4 : 0.5),
|
||||
border: alpha(palette.text.hex, isLight ? 0.08 : 0.12),
|
||||
highlight: alpha('#ffffff', isLight ? 0.65 : 0.08),
|
||||
borderStrong: alpha(palette.text.hex, isLight ? 0.16 : deep ? 0.28 : 0.22),
|
||||
highlight: alpha('#ffffff', isLight ? 0.65 : deep ? 0.16 : 0.08),
|
||||
blur: 'blur(18px) saturate(160%)',
|
||||
blurStrong: 'blur(28px) saturate(180%)',
|
||||
blurStrong: `blur(${deep ? 20 : 28}px) saturate(180%) brightness(${isLight ? 1.02 : 1.08})`,
|
||||
blurCard: 'blur(10px) saturate(150%)',
|
||||
rimWidth: deep ? 10 : 8,
|
||||
rimSheen: alpha('#ffffff', isLight ? 0.22 : deep ? 0.14 : 0.07),
|
||||
rimFilter: 'blur(2px) url(#hfr-rim)',
|
||||
rimFilterChroma: 'blur(1.5px) url(#hfr-rim-chroma)',
|
||||
// Light enters top-left, exits bottom-right. Keep the mid-band clear so
|
||||
// the refracted backdrop stays readable through the pane.
|
||||
specular: `linear-gradient(135deg, ${alpha('#ffffff', isLight ? 0.55 : deep ? 0.18 : 0.1)} 0%, ${alpha('#ffffff', 0)} 38%, ${alpha('#ffffff', 0)} 62%, ${alpha('#ffffff', isLight ? 0.18 : deep ? 0.07 : 0.04)} 100%)`,
|
||||
shadowInner: `inset 0 1px 0 ${alpha('#ffffff', isLight ? 0.7 : deep ? 0.2 : 0.1)}, inset 0 -1px 0 ${alpha('#000000', isLight ? 0.06 : 0.35)}`,
|
||||
},
|
||||
accent: {
|
||||
primary: palette.primary.hex,
|
||||
@@ -297,8 +363,21 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
sm: 10,
|
||||
md: 14,
|
||||
lg: 20,
|
||||
xl: 28,
|
||||
pill: 999,
|
||||
},
|
||||
motion: {
|
||||
fast: '140ms',
|
||||
base: '240ms',
|
||||
slow: '420ms',
|
||||
ease: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
spring: 'cubic-bezier(0.34, 1.36, 0.64, 1)',
|
||||
},
|
||||
terminal: {
|
||||
bg: alpha(palette.crust.hex, isLight ? 0.9 : 0.72),
|
||||
fg: isLight ? palette.text.hex : palette.subtext0.hex,
|
||||
border: alpha(palette.text.hex, isLight ? 0.12 : 0.18),
|
||||
},
|
||||
};
|
||||
|
||||
const themeOptions: ThemeOptions = {
|
||||
@@ -329,7 +408,10 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
borderRadius: tokens.radius.sm,
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Plus Jakarta Sans", "Inter", "Roboto", sans-serif',
|
||||
// "…Variable" is the family name @fontsource-variable registers; the
|
||||
// plain name is kept as a fallback for anyone who has it installed.
|
||||
fontFamily:
|
||||
'"Plus Jakarta Sans Variable", "Plus Jakarta Sans", "Inter", "Roboto", sans-serif',
|
||||
h1: { fontWeight: 800, letterSpacing: '-0.02em' },
|
||||
h2: { fontWeight: 800, letterSpacing: '-0.02em' },
|
||||
h3: { fontWeight: 700, letterSpacing: '-0.015em' },
|
||||
@@ -434,6 +516,7 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: tokens.surface.overlay,
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: tokens.glass.blurStrong,
|
||||
WebkitBackdropFilter: tokens.glass.blurStrong,
|
||||
border: '1px solid',
|
||||
@@ -442,6 +525,162 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
// Everything below exists so the glass reaches dialogs, admin tables and
|
||||
// toasts without per-page edits. Chip and Skeleton were the two largest
|
||||
// untreated surface populations in the app.
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: tokens.surface.overlay,
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: tokens.glass.blurStrong,
|
||||
WebkitBackdropFilter: tokens.glass.blurStrong,
|
||||
border: '1px solid',
|
||||
borderColor: tokens.glass.border,
|
||||
boxShadow: tokens.elevation.md,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPopover: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: tokens.surface.overlay,
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: tokens.glass.blurStrong,
|
||||
WebkitBackdropFilter: tokens.glass.blurStrong,
|
||||
border: '1px solid',
|
||||
borderColor: tokens.glass.border,
|
||||
boxShadow: tokens.elevation.md,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: tokens.surface.overlay,
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: tokens.glass.blurStrong,
|
||||
WebkitBackdropFilter: tokens.glass.blurStrong,
|
||||
border: '1px solid',
|
||||
borderColor: tokens.glass.border,
|
||||
boxShadow: tokens.elevation.md,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiBackdrop: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: alpha(palette.crust.hex, isLight ? 0.4 : 0.6),
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
},
|
||||
invisible: {
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'none',
|
||||
WebkitBackdropFilter: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
backgroundColor: tokens.surface.overlay,
|
||||
color: palette.text.hex,
|
||||
backdropFilter: tokens.glass.blurStrong,
|
||||
WebkitBackdropFilter: tokens.glass.blurStrong,
|
||||
border: '1px solid',
|
||||
borderColor: tokens.glass.border,
|
||||
boxShadow: tokens.elevation.md,
|
||||
borderRadius: tokens.radius.sm,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
arrow: { color: tokens.surface.overlay },
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: tokens.radius.pill,
|
||||
fontWeight: 600,
|
||||
border: '1px solid',
|
||||
borderColor: tokens.glass.border,
|
||||
},
|
||||
filled: {
|
||||
backgroundColor: tokens.glass.tint,
|
||||
backdropFilter: tokens.glass.blurCard,
|
||||
WebkitBackdropFilter: tokens.glass.blurCard,
|
||||
},
|
||||
outlined: {
|
||||
backgroundColor: tokens.glass.tintSubtle,
|
||||
backdropFilter: tokens.glass.blurCard,
|
||||
WebkitBackdropFilter: tokens.glass.blurCard,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSkeleton: {
|
||||
styleOverrides: {
|
||||
// Translucent so the backdrop still reads through a loading grid —
|
||||
// an opaque grey block would punch holes in the aurora.
|
||||
root: {
|
||||
backgroundColor: alpha(palette.text.hex, isLight ? 0.07 : 0.09),
|
||||
borderRadius: tokens.radius.sm,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableContainer: {
|
||||
styleOverrides: {
|
||||
root: { backgroundColor: 'transparent', backgroundImage: 'none' },
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: alpha(palette.surface0.hex, isLight ? 0.55 : 0.35),
|
||||
backdropFilter: tokens.glass.blurCard,
|
||||
WebkitBackdropFilter: tokens.glass.blurCard,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
styleOverrides: {
|
||||
root: { borderColor: tokens.glass.border },
|
||||
},
|
||||
},
|
||||
MuiAccordion: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: tokens.glass.tintSubtle,
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: tokens.glass.blurCard,
|
||||
WebkitBackdropFilter: tokens.glass.blurCard,
|
||||
border: '1px solid',
|
||||
borderColor: tokens.glass.border,
|
||||
borderRadius: tokens.radius.md,
|
||||
boxShadow: 'none',
|
||||
'&::before': { display: 'none' },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: tokens.radius.md,
|
||||
border: '1px solid',
|
||||
borderColor: tokens.glass.border,
|
||||
backdropFilter: tokens.glass.blur,
|
||||
WebkitBackdropFilter: tokens.glass.blur,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiLinearProgress: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: tokens.radius.pill,
|
||||
backgroundColor: alpha(palette.text.hex, isLight ? 0.08 : 0.12),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tokens,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user