ui redesign

This commit is contained in:
nvrl committed 2026-09-04 23:38:55 +02:00
1 parent d0a027a2a1
commit 67da3061a6
49 files changed
+1496 -458

No files matched your search

+3
View File
@@ -8,6 +8,7 @@
"@catppuccin/palette": "^1.7.1", "@catppuccin/palette": "^1.7.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.8",
"@tanstack/react-query": "^5.100.3", "@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=="], "@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/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=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
+35 -16
View File
@@ -8,23 +8,42 @@
</head> </head>
<body style="margin: 0; padding: 0;"> <body style="margin: 0; padding: 0;">
<script> <script>
(function() { // Paints the page ground before React mounts so there's no white flash.
const theme = localStorage.getItem('theme') || 'holy_light'; // Kept deliberately in sync with getTheme() in src/theme.ts — when a
const darkThemes = ['holy_dark', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'gruvbox', 'oceanic', 't0p_trench', 't0p_blurryface', 't0p_clancy']; // theme is added there, add its base colour here too, or it falls back to
const isDark = darkThemes.includes(theme); // a generic light/dark grey for the first frame.
(function () {
// Define primary background colors for common themes to prevent flash const bg = {
const bgColors = { holy_light: '#f8fafc',
'holy_light': '#f8fafc', holy_dark: '#020617',
'holy_dark': '#020617', latte: '#eff1f5',
'pink_pastel': '#fff1f2', frappe: '#303446',
'mocha': '#1e1e2e', macchiato: '#24273a',
't0p_sai': '#fdf2f8', mocha: '#1e1e2e',
't0p_trench': '#1a1a1a' 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',
}; };
const light = [
document.body.style.backgroundColor = bgColors[theme] || (isDark ? '#0f172a' : '#f8fafc'); 'holy_light',
document.body.style.color = isDark ? '#f8fafc' : '#0f172a'; '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> </script>
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -21,6 +21,7 @@
"@catppuccin/palette": "^1.7.1", "@catppuccin/palette": "^1.7.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.8",
"@tanstack/react-query": "^5.100.3", "@tanstack/react-query": "^5.100.3",
-42
View File
@@ -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
View File
@@ -7,7 +7,10 @@ import CookieBanner from './components/CookieBanner';
import { useAuth } from './hooks/useAuth'; import { useAuth } from './hooks/useAuth';
import { useTheme } from './hooks/useTheme'; import { useTheme } from './hooks/useTheme';
import { TopBar } from './components/layout/TopBar'; 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 { useCategories } from './api/queries/useCategories';
import { RequireSuperuser } from './components/auth/RequireSuperuser'; import { RequireSuperuser } from './components/auth/RequireSuperuser';
import type { CatppuccinTheme } from './theme'; import type { CatppuccinTheme } from './theme';
@@ -37,6 +40,11 @@ const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail')); const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail')); 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 App: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const muiTheme = useMuiTheme(); const muiTheme = useMuiTheme();
@@ -44,8 +52,13 @@ const App: React.FC = () => {
const { themeName, handleThemeChange } = useTheme(); const { themeName, handleThemeChange } = useTheme();
const { data: categories = [] } = useCategories(); 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(() => { 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.backgroundColor = muiTheme.palette.background.default;
document.body.style.color = muiTheme.palette.text.primary; document.body.style.color = muiTheme.palette.text.primary;
}, [muiTheme]); }, [muiTheme]);
@@ -55,19 +68,23 @@ const App: React.FC = () => {
}; };
return ( return (
<Box <BackdropArtProvider>
sx={{ {/* No transform/filter/opacity may be introduced on this element or on
display: 'flex', #root: either would create a stacking context that isolates the fixed
flexDirection: 'column', backdrop, and every backdrop-filter in the app would sample nothing
minHeight: '100vh', and go flat. */}
width: '100%', <Box
}} sx={{
> display: 'flex',
<TopBar /> flexDirection: 'column',
minHeight: '100vh',
width: '100%',
}}
>
<BackdropSlot reducedMotion={reducedMotion} />
<CategoryBar categories={categories} /> <TopBar categories={categories} />
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
<Box <Box
component="main" component="main"
sx={{ sx={{
@@ -143,10 +160,10 @@ const App: React.FC = () => {
</Box> </Box>
<Footer /> <Footer />
</Box> </Box>
</Box>
<CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} /> <CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} />
</Box> </Box>
</BackdropArtProvider>
); );
}; };
+3
View File
@@ -6,12 +6,15 @@ import { AuthProvider } from '../contexts/AuthContext';
import { NotificationProvider } from '../contexts/NotificationContext'; import { NotificationProvider } from '../contexts/NotificationContext';
import { ToastProvider } from '../contexts/ToastContext'; import { ToastProvider } from '../contexts/ToastContext';
import { ConfirmProvider } from '../contexts/ConfirmContext'; import { ConfirmProvider } from '../contexts/ConfirmContext';
import { GlassDefs } from '../components/ui/GlassDefs';
import { queryClient } from './queryClient'; import { queryClient } from './queryClient';
import '../i18n'; import '../i18n';
export const AppProviders = ({ children }: { children: ReactNode }) => ( export const AppProviders = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
{/* Mounted once so `url(#hfr-…)` resolves for every glass rim in the app. */}
<GlassDefs />
<ToastProvider> <ToastProvider>
<AuthProvider> <AuthProvider>
<NotificationProvider> <NotificationProvider>
+2 -1
View File
@@ -9,6 +9,7 @@ import {
DialogActions, DialogActions,
Box, Box,
} from '@mui/material'; } from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { CatppuccinTheme } from '../theme'; import type { CatppuccinTheme } from '../theme';
@@ -45,7 +46,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
border: '1px solid', border: '1px solid',
borderColor: 'primary.main', borderColor: 'primary.main',
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98), 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)}`, boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
}, },
}} }}
+7 -3
View File
@@ -10,14 +10,18 @@ const Footer: React.FC = () => {
return ( return (
<Box <Box
component="footer" component="footer"
sx={{ sx={(theme) => ({
bgcolor: 'background.paper', // 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, pt: 6,
pb: 4, pb: 4,
mt: 'auto', mt: 'auto',
borderTop: '1px solid', borderTop: '1px solid',
borderColor: 'divider', borderColor: 'divider',
}} })}
> >
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box <Box
+1 -1
View File
@@ -168,7 +168,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
style={{ zIndex: 2000, width: 250 }} style={{ zIndex: 2000, width: 250 }}
> >
<Paper <Paper
elevation={8} elevation={0}
sx={{ sx={{
mt: 1, mt: 1,
borderRadius: 2, borderRadius: 2,
+9 -1
View File
@@ -45,7 +45,15 @@ export const BrandMark = ({ onNavigate, compact = false }: BrandMarkProps) => {
> >
<Typography <Typography
component="span" 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 H
</Typography> </Typography>
+66 -73
View File
@@ -12,10 +12,13 @@ export interface CategoryBarProps {
} }
/** /**
* Horizontal nav pinned just under the top bar on every breakpoint. Categories * The browse row. Categories are the dominant browse axis, so they get a
* are the dominant browse axis — laid out as a scrollable row of tabs — with * scrollable row of their own with Home / Community / Dashboard leading it.
* Home / Community / Dashboard as primary links on the left. Replaces the old *
* left rail on desktop and mirrors the same sticky chrome on mobile. * 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) => { export const CategoryBar = ({ categories }: CategoryBarProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -27,12 +30,16 @@ export const CategoryBar = ({ categories }: CategoryBarProps) => {
const tabSx = (active: boolean) => ({ const tabSx = (active: boolean) => ({
flexShrink: 0, flexShrink: 0,
borderRadius: 2, borderRadius: (theme: Theme) => `${theme.tokens.radius.pill}px`,
px: 1.75, px: 1.75,
py: 0.75, py: 0.75,
fontWeight: active ? 700 : 500, fontWeight: active ? 700 : 500,
color: active ? 'primary.main' : 'text.secondary', 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': { '&:hover': {
color: 'text.primary', color: 'text.primary',
bgcolor: 'action.hover', bgcolor: 'action.hover',
@@ -43,80 +50,66 @@ export const CategoryBar = ({ categories }: CategoryBarProps) => {
<Box <Box
component="nav" component="nav"
sx={{ sx={{
position: 'sticky', maxWidth: 1400,
top: { xs: 56, sm: 64 }, mx: 'auto',
zIndex: (theme) => theme.zIndex.appBar - 1, px: { xs: 1, sm: 4, md: 6 },
borderBottom: '1px solid', display: 'flex',
borderColor: (theme) => theme.tokens.glass.border, alignItems: 'center',
backgroundColor: (theme) => gap: { xs: 0.5, md: 1 },
alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.8 : 0.6), minHeight: { xs: 46, md: 52 },
backdropFilter: (theme) => theme.tokens.glass.blurStrong, overflowX: 'auto',
WebkitBackdropFilter: (theme) => theme.tokens.glass.blurStrong, '&::-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 <Box
sx={{ sx={{
maxWidth: 1400, alignSelf: 'stretch',
mx: 'auto', my: 1.25,
px: { xs: 1, sm: 4, md: 6 }, mx: 0.5,
display: 'flex', borderLeft: '1px solid',
alignItems: 'center', borderColor: 'divider',
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 && ( {categories.map((cat) => {
<Button component={Link} to="/community" disableRipple sx={tabSx(isActive('/community'))}> const path = `/category/${cat.slug}`;
<GroupsOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} /> return (
{t('nav.community')} <Button
key={cat.slug}
component={Link}
to={path}
disableRipple
sx={tabSx(location.pathname === path)}
>
{t(`categories.${cat.slug}`, { defaultValue: cat.name })}
</Button> </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> </Box>
); );
}; };
+8 -2
View File
@@ -22,20 +22,25 @@ import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettin
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined'; import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GlassAppBar } from '../ui'; import { GlassAppBar } from '../ui';
import { CategoryBar } from './CategoryBar';
import { GlobalSearch } from '../../app/GlobalSearch'; import { GlobalSearch } from '../../app/GlobalSearch';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { BrandMark } from './BrandMark'; import { BrandMark } from './BrandMark';
import { NotificationMenu } from './NotificationMenu'; import { NotificationMenu } from './NotificationMenu';
export interface TopBarProps {
categories: { name: string; slug: string }[];
}
/** /**
* Top bar for every breakpoint. Promotes global search to the primary, centered * 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 * slot; notifications + an account menu sit on the right. Everything that used to
* be a flat sidebar row (profile, dashboard, settings, support, admin, following, * 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 * 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 * 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 { t } = useTranslation();
const { user, following, logout } = useAuth(); const { user, following, logout } = useAuth();
@@ -49,6 +54,7 @@ export const TopBar = () => {
return ( return (
<GlassAppBar <GlassAppBar
toolbarSx={{ maxWidth: 1400, width: '100%', mx: 'auto', px: { xs: 1, sm: 4, md: 6 } }} toolbarSx={{ maxWidth: 1400, width: '100%', mx: 'auto', px: { xs: 1, sm: 4, md: 6 } }}
secondary={<CategoryBar categories={categories} />}
> >
<BrandMark /> <BrandMark />
+121
View File
@@ -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>
);
+120 -109
View File
@@ -1,9 +1,9 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { alpha, useTheme } from '@mui/material/styles'; import { alpha, useTheme } from '@mui/material/styles';
import { GlassCard } from './Glass'; import { GlassCard } from './Glass';
import { glassSx } from './glass-style';
import RatingBadge from '../RatingBadge'; import RatingBadge from '../RatingBadge';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
@@ -24,6 +24,17 @@ export interface FlavorCardProps {
compact?: boolean; 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 = ({ export const FlavorCard = ({
flavor, flavor,
caption, caption,
@@ -32,6 +43,8 @@ export const FlavorCard = ({
}: FlavorCardProps) => { }: FlavorCardProps) => {
const theme = useTheme(); const theme = useTheme();
const hasRating = typeof flavor.average_rating === 'number' && flavor.average_rating > 0; const hasRating = typeof flavor.average_rating === 'number' && flavor.average_rating > 0;
const hasImage = !!flavor.image_url;
const inset = compact ? 6 : 8;
return ( return (
<Link <Link
@@ -39,32 +52,23 @@ export const FlavorCard = ({
style={{ textDecoration: 'none', color: 'inherit', display: 'block', height: '100%' }} style={{ textDecoration: 'none', color: 'inherit', display: 'block', height: '100%' }}
> >
<GlassCard <GlassCard
tier="card"
intensity="subtle" intensity="subtle"
interactive
sx={{ sx={{
height: '100%', // Width-driven, not height-driven: with `height: 100%` the grid row
display: 'flex', // stretches the card and `aspect-ratio` then derives a width wider
flexDirection: 'column', // than the column, so the artwork spills past the rounded corners.
width: '100%',
height: 'auto',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
transition: 'border-color 200ms ease, box-shadow 200ms ease, background-color 200ms ease', aspectRatio: '4 / 5',
'&:hover': { ...(hasImage ? { backdropFilter: 'none', WebkitBackdropFilter: 'none' } : {}),
borderColor: alpha(theme.palette.primary.main, 0.5), '&:hover .flavor-card-image': { transform: 'scale(1.05)' },
boxShadow: theme.tokens.elevation.md,
'& .flavor-card-image': { transform: 'scale(1.04)' },
},
}} }}
> >
<Box <Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
sx={{
position: 'relative',
width: '100%',
aspectRatio: '1 / 1',
bgcolor: 'background.default',
borderBottom: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
{flavor.image_url ? ( {flavor.image_url ? (
<Box <Box
component="img" component="img"
@@ -76,7 +80,7 @@ export const FlavorCard = ({
width: '100%', width: '100%',
height: '100%', height: '100%',
objectFit: 'cover', 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', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: alpha(theme.palette.primary.main, 0.04), // 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" 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),
}} }}
> >
<Typography <Typography
variant="caption" variant="h3"
sx={{ sx={{ fontWeight: 800, opacity: 0.35, letterSpacing: '-0.03em' }}
fontWeight: 700,
fontSize: '0.6rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'text.secondary',
lineHeight: 1,
}}
noWrap
> >
{flavor.category_name} {flavor.name.charAt(0).toUpperCase()}
</Typography> </Typography>
</Box> </Box>
)} )}
</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={{ 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', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: compact ? 0.5 : 0.75, gap: 0.5,
p: compact ? 1.5 : 2, px: compact ? 1.15 : 1.5,
'&:last-child': { pb: compact ? 1.5 : 2 }, py: compact ? 0.9 : 1.15,
}} }}
> >
<Typography <Typography
variant="subtitle1" variant="subtitle1"
sx={{ sx={{
position: 'relative',
zIndex: 1,
fontWeight: 700, fontWeight: 700,
fontSize: compact ? '0.9rem' : '1rem', fontSize: compact ? '0.85rem' : '0.98rem',
lineHeight: 1.25, lineHeight: 1.25,
letterSpacing: '-0.01em', letterSpacing: '-0.01em',
}} }}
@@ -177,29 +185,32 @@ export const FlavorCard = ({
{flavor.name} {flavor.name}
</Typography> </Typography>
<Box {(hasRating || caption) && (
sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', position: 'relative',
justifyContent: 'space-between', zIndex: 1,
gap: 1, display: 'flex',
mt: 'auto', alignItems: 'center',
minHeight: 24, justifyContent: 'space-between',
}} gap: 1,
> minHeight: 22,
{hasRating && <RatingBadge score={flavor.average_rating || 0} size="small" />} }}
{caption && ( >
<Typography {hasRating && <RatingBadge score={flavor.average_rating || 0} size="small" />}
variant="caption" {caption && (
color="text.secondary" <Typography
noWrap variant="caption"
sx={{ fontWeight: 600, fontSize: '0.7rem', ml: 'auto' }} color="text.secondary"
> noWrap
{caption} sx={{ fontWeight: 600, fontSize: '0.7rem', ml: 'auto' }}
</Typography> >
)} {caption}
</Box> </Typography>
</CardContent> )}
</Box>
)}
</Box>
</GlassCard> </GlassCard>
</Link> </Link>
); );
@@ -34,6 +34,13 @@ export const FlavorGrid = ({ children, compact = false, sx }: FlavorGridProps) =
gap: compact ? 2 : 3, gap: compact ? 2 : 3,
width: '100%', width: '100%',
minWidth: 0, 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, ...sx,
}} }}
> >
+1 -1
View File
@@ -33,7 +33,7 @@ export const FlavorThumb = ({ src, name, size = 64, radius = 1.5, sx }: FlavorTh
flexShrink: 0, flexShrink: 0,
borderRadius: radius, borderRadius: radius,
overflow: 'hidden', overflow: 'hidden',
bgcolor: 'background.default', bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.06),
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
display: 'flex', display: 'flex',
+55
View File
@@ -9,6 +9,20 @@ const collectStyles = () =>
.map((s) => s.textContent ?? '') .map((s) => s.textContent ?? '')
.join('\n'); .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 renderWithTheme = (ui: React.ReactElement, mode: CatppuccinTheme = 'mocha') => {
const theme = getTheme(mode); const theme = getTheme(mode);
const result = render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>); const result = render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
@@ -61,6 +75,47 @@ describe('Glass primitives', () => {
expect(getByTestId('g').getAttribute('intensity')).toBeNull(); 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', () => { it('GlassPaper + GlassSurface emit glass tint on a light theme', () => {
const { theme } = renderWithTheme( const { theme } = renderWithTheme(
<> <>
+13 -49
View File
@@ -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 MuiCard from '@mui/material/Card';
import MuiPaper from '@mui/material/Paper'; import MuiPaper from '@mui/material/Paper';
import Box from '@mui/material/Box'; 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 = export const GlassCard = styled(MuiCard, { shouldForwardProp: forwardable })<GlassOptions>(
(intensity: Intensity) => ({ theme, ...options }) => glassSx(theme, options),
({ 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}`,
},
};
};
interface GlassProps { export const GlassPaper = styled(MuiPaper, { shouldForwardProp: forwardable })<GlassOptions>(
intensity?: Intensity; ({ theme, ...options }) => glassSx(theme, options),
} );
export const GlassCard = styled(MuiCard, { export const GlassSurface = styled(Box, { shouldForwardProp: forwardable })<GlassOptions>(
shouldForwardProp: (prop) => prop !== 'intensity', ({ theme, ...options }) => glassSx(theme, options),
})<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 };
+84 -32
View File
@@ -1,36 +1,42 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { styled, alpha } from '@mui/material/styles';
import type { SxProps, Theme } 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'; import Toolbar from '@mui/material/Toolbar';
import { GlassSurface } from './Glass';
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',
};
});
export interface GlassAppBarProps { export interface GlassAppBarProps {
/** Primary row content — brand, search, account. */
children: ReactNode; children: ReactNode;
/** Optional second row inside the same pane (the category scroller). */
secondary?: ReactNode;
threshold?: number; threshold?: number;
/** Extra styles merged onto the inner Toolbar — e.g. to constrain its content /** 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. */ * to the page-content column so the bar aligns with the main content edges. */
toolbarSx?: SxProps<Theme>; 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); const [scrolled, setScrolled] = useState(false);
useEffect(() => { useEffect(() => {
@@ -41,17 +47,63 @@ export const GlassAppBar = ({ children, threshold = 12, toolbarSx }: GlassAppBar
}, [threshold]); }, [threshold]);
return ( return (
<StyledAppBar position="sticky" elevation={0} scrolled={scrolled}> <Box
<Toolbar component="header"
sx={ sx={(theme) => ({
[ position: 'sticky',
{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }, top: { xs: 0, md: theme.spacing(1.5) },
toolbarSx || {}, zIndex: theme.zIndex.appBar,
] as SxProps<Theme> 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
</Toolbar> disableGutters
</StyledAppBar> 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>
); );
}; };
+143
View File
@@ -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} />;
+2 -1
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; 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 { Box, ButtonBase, IconButton, Modal, Typography, alpha, useTheme } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
@@ -294,7 +295,7 @@ const HeroGallery: React.FC<HeroGalleryProps> = ({ images, alt, badge, enableLig
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backdropFilter: 'blur(8px)', backdropFilter: (theme: Theme) => theme.tokens.glass.blurCard,
}} }}
slotProps={{ slotProps={{
backdrop: { backdrop: {
+1
View File
@@ -31,6 +31,7 @@ export const ReviewRow = ({ review, showFlavor = true, compact }: ReviewRowProps
style={{ textDecoration: 'none', color: 'inherit', display: 'block' }} style={{ textDecoration: 'none', color: 'inherit', display: 'block' }}
> >
<GlassCard <GlassCard
tier="card"
intensity="subtle" intensity="subtle"
sx={{ sx={{
display: 'flex', display: 'flex',
+142
View File
@@ -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 };
+4 -2
View File
@@ -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 { GlassAppBar, type GlassAppBarProps } from './GlassAppBar';
export { PageShell, type PageShellProps } from './PageShell'; export { PageShell, type PageShellProps } from './PageShell';
export { SectionHeader, type SectionHeaderProps } from './SectionHeader'; export { SectionHeader, type SectionHeaderProps } from './SectionHeader';
export { HeroBackdrop, type HeroBackdropProps } from './HeroBackdrop';
export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow'; export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow';
export { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard'; export { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard';
export { FlavorThumb, type FlavorThumbProps } from './FlavorThumb'; 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);
+24
View File
@@ -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);
});
});
+90
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
:root { :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; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-synthesis: none; font-synthesis: none;
+4
View File
@@ -1,6 +1,10 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom'; 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 './index.css';
import App from './App.tsx'; import App from './App.tsx';
import { AppProviders } from './app/providers'; import { AppProviders } from './app/providers';
+2 -2
View File
@@ -4,14 +4,14 @@ import { Link } from 'react-router-dom';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTitle } from '../hooks/useTitle'; 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 About: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useTitle(t('nav.about')); useTitle(t('nav.about'));
return ( return (
<PageShell hero={<HeroBackdrop variant="minimal" />}> <PageShell>
<Button <Button
variant="outlined" variant="outlined"
component={Link} component={Link}
+4 -5
View File
@@ -53,7 +53,6 @@ import RatingBadge from '../components/RatingBadge';
import ReactionBar from '../components/ReactionBar'; import ReactionBar from '../components/ReactionBar';
import { import {
PageShell, PageShell,
HeroBackdrop,
SectionHeader, SectionHeader,
GlassCard, GlassCard,
FlavorThumb, FlavorThumb,
@@ -160,7 +159,7 @@ const SuggestedFollows: React.FC = () => {
const FeedSkeleton: React.FC = () => ( const FeedSkeleton: React.FC = () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<GlassCard key={i} intensity="default"> <GlassCard key={i} intensity="default" tier="card">
<CardContent sx={{ p: { xs: 2, sm: 3 } }}> <CardContent sx={{ p: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2 }}> <Box sx={{ display: 'flex', gap: 2 }}>
@@ -283,7 +282,7 @@ const CommunityFeed: React.FC = () => {
const expanded = !!expandedReplies[rating.id]; const expanded = !!expandedReplies[rating.id];
const latestReply = rating.replies[rating.replies.length - 1]; const latestReply = rating.replies[rating.replies.length - 1];
return ( return (
<GlassCard key={rating.id} intensity="default"> <GlassCard key={rating.id} intensity="default" tier="card">
<CardContent sx={{ p: { xs: 2, sm: 3 } }}> <CardContent sx={{ p: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2 }}> <Box sx={{ display: 'flex', gap: 2 }}>
@@ -489,7 +488,7 @@ const CommunityFeed: React.FC = () => {
const renderActivityCard = (item: ActivityItem) => { const renderActivityCard = (item: ActivityItem) => {
const isMilestone = item.kind === 'milestone'; const isMilestone = item.kind === 'milestone';
return ( return (
<GlassCard key={item.id} intensity="default"> <GlassCard key={item.id} intensity="default" tier="card">
<CardContent sx={{ p: { xs: 2, sm: 2.5 } }}> <CardContent sx={{ p: { xs: 2, sm: 2.5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box <Box
@@ -545,7 +544,7 @@ const CommunityFeed: React.FC = () => {
: t('community.subtitle'); : t('community.subtitle');
return ( return (
<PageShell hero={<HeroBackdrop variant="minimal" />}> <PageShell>
<BackButton /> <BackButton />
<SectionHeader title={t('community.title')} subtitle={subtitle} /> <SectionHeader title={t('community.title')} subtitle={subtitle} />
+62 -14
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Typography, Typography,
Box, Box,
@@ -9,6 +9,7 @@ import {
IconButton, IconButton,
Skeleton, Skeleton,
useMediaQuery, useMediaQuery,
useTheme,
} from '@mui/material'; } from '@mui/material';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -28,11 +29,14 @@ import {
GlassCard, GlassCard,
PageShell, PageShell,
SectionHeader, SectionHeader,
HeroBackdrop,
FlavorCard, FlavorCard,
FlavorGrid, FlavorGrid,
EmptyState, EmptyState,
glassSx,
} from '../components/ui'; } from '../components/ui';
import { alpha } from '@mui/material/styles';
import { GenerativeFlowBanner } from '../components/banners';
import { useBackdropArt } from '../hooks/useBackdropArt';
import MainPageRecommendations from './MainPageRecommendations'; import MainPageRecommendations from './MainPageRecommendations';
import { CategoryBrowseGrid } from '../components/home/DesktopHomeSections'; import { CategoryBrowseGrid } from '../components/home/DesktopHomeSections';
import { useCategories } from '../api/queries/useCategories'; import { useCategories } from '../api/queries/useCategories';
@@ -59,6 +63,7 @@ const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => (
const HeroCarouselSkeleton: React.FC = () => ( const HeroCarouselSkeleton: React.FC = () => (
<GlassCard <GlassCard
intensity="strong" intensity="strong"
tier="panel"
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: { xs: 'column', md: 'row' }, flexDirection: { xs: 'column', md: 'row' },
@@ -86,6 +91,8 @@ const HeroCarouselSkeleton: React.FC = () => (
const MainPage: React.FC = () => { const MainPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme();
const isLight = theme.palette.mode === 'light';
const location = useLocation(); const location = useLocation();
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
// Auto-advance stops for good once the user takes manual control; hover only // 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'); 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 = () => { const handlePrev = () => {
if (topFlavors.length === 0) return; if (topFlavors.length === 0) return;
setAutoPlay(false); setAutoPlay(false);
@@ -187,7 +210,7 @@ const MainPage: React.FC = () => {
// --- HOME VIEW --- // --- HOME VIEW ---
return ( return (
<PageShell hero={<HeroBackdrop variant={isLoggedIn ? 'minimal' : 'mesh'} />}> <PageShell>
{/* Hero */} {/* Hero */}
{isLoggedIn ? ( {isLoggedIn ? (
<Box sx={{ mb: 1 }}> <Box sx={{ mb: 1 }}>
@@ -218,6 +241,7 @@ const MainPage: React.FC = () => {
) : ( ) : (
<GlassCard <GlassCard
intensity="strong" intensity="strong"
tier="panel"
sx={{ sx={{
p: { xs: 4, md: 8 }, p: { xs: 4, md: 8 },
textAlign: 'center', textAlign: 'center',
@@ -309,6 +333,7 @@ const MainPage: React.FC = () => {
<> <>
<GlassCard <GlassCard
intensity="strong" intensity="strong"
tier="panel"
role="region" role="region"
aria-roledescription="carousel" aria-roledescription="carousel"
aria-label={t('home.hallOfFame')} aria-label={t('home.hallOfFame')}
@@ -336,9 +361,8 @@ const MainPage: React.FC = () => {
left: 8, left: 8,
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
zIndex: 2, zIndex: 2,
bgcolor: 'background.paper', ...glassSx(theme, { intensity: 'strong', tier: 'card' }),
boxShadow: 2, borderRadius: '50%',
'&:hover': { bgcolor: 'background.paper' },
}} }}
> >
<ChevronLeftIcon /> <ChevronLeftIcon />
@@ -353,20 +377,25 @@ const MainPage: React.FC = () => {
right: 8, right: 8,
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
zIndex: 2, zIndex: 2,
bgcolor: 'background.paper', ...glassSx(theme, { intensity: 'strong', tier: 'card' }),
boxShadow: 2, borderRadius: '50%',
'&:hover': { bgcolor: 'background.paper' },
}} }}
> >
<ChevronRightIcon /> <ChevronRightIcon />
</IconButton> </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 <Box
component={Link} component={Link}
to={`/flavor/${currentTop.id}`} to={`/flavor/${currentTop.id}`}
sx={{ sx={{
width: { xs: '100%', md: '45%' }, position: { xs: 'relative', md: 'absolute' },
inset: { md: 0 },
width: '100%',
height: { md: '100%' }, height: { md: '100%' },
aspectRatio: { xs: '16/9', md: 'auto' }, aspectRatio: { xs: '16/9', md: 'auto' },
display: 'flex', display: 'flex',
@@ -375,7 +404,6 @@ const MainPage: React.FC = () => {
bgcolor: 'action.hover', bgcolor: 'action.hover',
overflow: 'hidden', overflow: 'hidden',
textDecoration: 'none', textDecoration: 'none',
position: 'relative',
}} }}
> >
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}> <Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
@@ -390,17 +418,36 @@ const MainPage: React.FC = () => {
src={currentTop.image_url} src={currentTop.image_url}
alt={currentTop.name} alt={currentTop.name}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy" loading="eager"
/> />
)} )}
</Box> </Box>
<CardContent <CardContent
sx={{ sx={{
flex: 1, position: 'relative',
p: { xs: 3, sm: 4, md: 6 }, zIndex: 1,
flex: { xs: 1, md: '0 0 auto' },
width: { xs: '100%', md: '50%' },
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', 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 <Chip
@@ -438,6 +485,7 @@ const MainPage: React.FC = () => {
{featuredReview && ( {featuredReview && (
<GlassCard <GlassCard
intensity="subtle" intensity="subtle"
tier="card"
sx={{ mt: 1, p: { xs: 2, md: 2.5 }, boxShadow: 'none' }} sx={{ mt: 1, p: { xs: 2, md: 2.5 }, boxShadow: 'none' }}
> >
<Typography <Typography
+2 -2
View File
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import { useTranslation } from 'react-i18next'; 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 Privacy: React.FC = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -18,7 +18,7 @@ const Privacy: React.FC = () => {
}; };
return ( return (
<PageShell hero={<HeroBackdrop variant="minimal" />}> <PageShell>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
+49 -16
View File
@@ -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 { useParams, useNavigate, Link } from 'react-router-dom';
import { import {
Typography, Typography,
@@ -43,6 +43,7 @@ import { useTranslation } from 'react-i18next';
import RatingBadge from '../components/RatingBadge'; import RatingBadge from '../components/RatingBadge';
import RichText from '../components/RichText'; import RichText from '../components/RichText';
import DynamicBanner from '../components/DynamicBanner'; import DynamicBanner from '../components/DynamicBanner';
import { useBackdropArt } from '../hooks/useBackdropArt';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../components/ui'; import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast'; import { useToast } from '../hooks/useToast';
@@ -155,6 +156,22 @@ const PublicProfile: React.FC = () => {
useTitle(data ? `${data.username}'s Ratings` : 'Profile'); 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) if (loading)
return ( return (
<PageShell> <PageShell>
@@ -184,6 +201,14 @@ const PublicProfile: React.FC = () => {
const currentRatings: PublicProfileRating[] = const currentRatings: PublicProfileRating[] =
categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]]; 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 = [ const tiers = [
{ key: 'S', title: 'S-Tier', color: '#ff7f7f' }, { key: 'S', title: 'S-Tier', color: '#ff7f7f' },
{ key: 'A', title: 'A-Tier', color: '#ffbf7f' }, { key: 'A', title: 'A-Tier', color: '#ffbf7f' },
@@ -301,16 +326,14 @@ const PublicProfile: React.FC = () => {
{t('common.back')} {t('common.back')}
</Button> </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' }}> <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 }} /> <Box sx={{ position: 'absolute', inset: 0, background: abstractBanner }} />
<DynamicBanner
username={data.username}
palette={palette}
ratingsCount={data.ratings.length}
followersCount={data.followers_count}
/>
</Box> </Box>
<CardContent <CardContent
@@ -331,7 +354,7 @@ const PublicProfile: React.FC = () => {
p: 0.75, p: 0.75,
borderRadius: '50%', borderRadius: '50%',
background: `linear-gradient(135deg, ${bannerColors[0]}, ${bannerColors[2]})`, 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', display: 'flex',
}} }}
> >
@@ -546,12 +569,21 @@ const PublicProfile: React.FC = () => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
bgcolor: tier.color, // Tier colours are fixed by convention (S red →
color: 'black', // 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, fontWeight: 900,
fontSize: '2rem', fontSize: '2rem',
borderRadius: 0.5, borderRadius: `${theme.tokens.radius.sm}px`,
boxShadow: `0 0 20px ${alpha(tier.color, 0.6)}`, boxShadow: `0 0 24px ${alpha(tier.color, 0.45)}`,
}} }}
> >
{tier.key} {tier.key}
@@ -734,8 +766,9 @@ const PublicProfile: React.FC = () => {
sx={{ sx={{
width: 48, width: 48,
height: 48, height: 48,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)', boxShadow: theme.tokens.elevation.sm,
border: '2px solid white', border: '2px solid',
borderColor: theme.tokens.glass.borderStrong,
}} }}
> >
{comment.author_username.charAt(0).toUpperCase()} {comment.author_username.charAt(0).toUpperCase()}
+1 -1
View File
@@ -272,7 +272,7 @@ const Support: React.FC = () => {
p: 1.5, p: 1.5,
borderRadius: 2, borderRadius: 2,
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected', 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, boxShadow: msg.is_admin ? 2 : 0,
}} }}
> >
+2 -2
View File
@@ -6,7 +6,7 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import api from '../lib/api'; import api from '../lib/api';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import { PageShell, HeroBackdrop, GlassCard } from '../components/ui'; import { PageShell, GlassCard } from '../components/ui';
type VerifyStatus = 'loading' | 'success' | 'error'; type VerifyStatus = 'loading' | 'success' | 'error';
@@ -50,7 +50,7 @@ const VerifyEmail: React.FC = () => {
}, [linkValid, username, code, navigate]); }, [linkValid, username, code, navigate]);
return ( return (
<PageShell hero={<HeroBackdrop variant="minimal" />}> <PageShell>
<GlassCard <GlassCard
intensity="subtle" intensity="subtle"
sx={{ p: 6, textAlign: 'center', maxWidth: 520, width: '100%', mx: 'auto' }} sx={{ p: 6, textAlign: 'center', maxWidth: 520, width: '100%', mx: 'auto' }}
+3 -3
View File
@@ -1,9 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { GlassCard } from '../../components/ui';
import { import {
Autocomplete, Autocomplete,
Box, Box,
Button, Button,
Card,
CardContent, CardContent,
Dialog, Dialog,
DialogActions, DialogActions,
@@ -73,7 +73,7 @@ const AdminFlavors: React.FC = () => {
{t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })} {t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })}
</Typography> </Typography>
<Card sx={{ mb: 4 }}> <GlassCard sx={{ mb: 4 }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
{t('admin.mergeFlavors', { defaultValue: 'Merge Flavors' })} {t('admin.mergeFlavors', { defaultValue: 'Merge Flavors' })}
@@ -143,7 +143,7 @@ const AdminFlavors: React.FC = () => {
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
</Card> </GlassCard>
<Dialog open={confirmOpen} onClose={() => !isMerging && setConfirmOpen(false)}> <Dialog open={confirmOpen} onClose={() => !isMerging && setConfirmOpen(false)}>
<DialogTitle> <DialogTitle>
+7 -6
View File
@@ -198,15 +198,16 @@ const AdminJobs = () => {
{t('admin.jobLastOutput')} {t('admin.jobLastOutput')}
</Typography> </Typography>
<Box <Box
sx={{ sx={(theme) => ({
p: 1.5, p: 1.5,
bgcolor: '#1e1e1e', bgcolor: theme.tokens.terminal.bg,
color: '#d4d4d4', color: theme.tokens.terminal.fg,
borderRadius: 2, borderRadius: `${theme.tokens.radius.sm}px`,
maxHeight: 150, maxHeight: 150,
overflowY: 'auto', overflowY: 'auto',
border: '1px solid #333', border: '1px solid',
}} borderColor: theme.tokens.terminal.border,
})}
> >
<pre <pre
style={{ style={{
+1 -1
View File
@@ -353,7 +353,7 @@ const AdminTickets: React.FC = () => {
p: 1.5, p: 1.5,
borderRadius: 2, borderRadius: 2,
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected', 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, boxShadow: msg.is_admin ? 2 : 0,
}} }}
> >
@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors'; import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors';
import { import {
PageShell, PageShell,
HeroBackdrop,
SectionHeader, SectionHeader,
FlavorCard, FlavorCard,
FlavorGrid, FlavorGrid,
@@ -32,7 +31,7 @@ const CategoryFlavors: React.FC = () => {
); );
return ( return (
<PageShell hero={<HeroBackdrop variant="mesh" />}> <PageShell>
<BackButton /> <BackButton />
<SectionHeader title={categoryName || t('home.flavorsFallback')} /> <SectionHeader title={categoryName || t('home.flavorsFallback')} />
@@ -145,7 +145,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
p: 0.5, p: 0.5,
borderRadius: '50%', borderRadius: '50%',
background: `linear-gradient(135deg, ${grad[0]}, ${grad[1]})`, 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', display: 'flex',
flexShrink: 0, flexShrink: 0,
}} }}
@@ -107,7 +107,7 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
}; };
return ( return (
<GlassCard> <GlassCard tier="card">
<CardContent sx={{ p: { xs: 2, sm: 3 } }}> <CardContent sx={{ p: { xs: 2, sm: 3 } }}>
{isEditing ? ( {isEditing ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -1,4 +1,6 @@
import React from 'react'; 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 { Box, CardContent, Chip, Tooltip, Typography } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import GroupsIcon from '@mui/icons-material/Groups'; import GroupsIcon from '@mui/icons-material/Groups';
@@ -31,6 +33,7 @@ const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
return ( return (
<GlassCard <GlassCard
tier="card"
intensity="subtle" intensity="subtle"
sx={{ sx={{
height: '100%', height: '100%',
@@ -48,7 +51,7 @@ const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
sx={{ sx={{
position: 'relative', position: 'relative',
aspectRatio: '1/1', aspectRatio: '1/1',
bgcolor: 'background.default', bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.06),
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
+25 -2
View File
@@ -25,6 +25,7 @@ import {
} from '../../api/mutations/useRatingMutations'; } from '../../api/mutations/useRatingMutations';
import { useUpdateReply } from '../../api/mutations/useReplyMutations'; import { useUpdateReply } from '../../api/mutations/useReplyMutations';
import { useTitle } from '../../hooks/useTitle'; import { useTitle } from '../../hooks/useTitle';
import { useBackdropArt } from '../../hooks/useBackdropArt';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import VerifiedIcon from '@mui/icons-material/Verified'; import VerifiedIcon from '@mui/icons-material/Verified';
import CommentIcon from '@mui/icons-material/Comment'; import CommentIcon from '@mui/icons-material/Comment';
@@ -36,7 +37,6 @@ import RatingBadge from '../../components/RatingBadge';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { import {
PageShell, PageShell,
HeroBackdrop,
GlassCard, GlassCard,
SectionHeader, SectionHeader,
EmptyState, EmptyState,
@@ -86,6 +86,29 @@ const FlavorDetail: React.FC = () => {
const galleryImages = useMemo(() => flavor?.image_urls ?? [], [flavor?.image_urls]); 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 handleReplySubmit = async (ratingId: number) => {
const text = replyInputs[ratingId]; const text = replyInputs[ratingId];
if (!text) return; if (!text) return;
@@ -162,7 +185,7 @@ const FlavorDetail: React.FC = () => {
); );
return ( return (
<PageShell hero={<HeroBackdrop variant="minimal" />}> <PageShell>
<BackButton /> <BackButton />
<Grid container spacing={4}> <Grid container spacing={4}>
@@ -1,4 +1,6 @@
import React from 'react'; 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 { Box, CardContent, Chip, Skeleton, Stack, Tooltip, Typography } from '@mui/material';
import RecommendIcon from '@mui/icons-material/Recommend'; import RecommendIcon from '@mui/icons-material/Recommend';
import GroupsIcon from '@mui/icons-material/Groups'; import GroupsIcon from '@mui/icons-material/Groups';
@@ -36,7 +38,7 @@ const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => {
sx={{ sx={{
position: 'relative', position: 'relative',
aspectRatio: '1/1', aspectRatio: '1/1',
bgcolor: 'background.default', bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.06),
borderBottom: '1px solid', borderBottom: '1px solid',
borderColor: 'divider', borderColor: 'divider',
overflow: 'hidden', overflow: 'hidden',
+242 -3
View File
@@ -21,10 +21,27 @@ export interface ThemeTokens {
tint: string; tint: string;
tintStrong: string; tintStrong: string;
tintSubtle: string; tintSubtle: string;
/** Tint for the floating nav / command pane — sits over live content. */
tintChrome: string;
border: string; border: string;
borderStrong: string;
highlight: string; highlight: string;
blur: string; blur: string;
blurStrong: 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: { accent: {
primary: string; primary: string;
@@ -43,8 +60,23 @@ export interface ThemeTokens {
sm: number; sm: number;
md: number; md: number;
lg: number; lg: number;
xl: number;
pill: 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 = export type CatppuccinTheme =
@@ -91,6 +123,23 @@ interface ThemePalette {
secondary: PaletteColor; 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) => { export const getTheme = (mode: CatppuccinTheme) => {
let palette: ThemePalette; let palette: ThemePalette;
@@ -258,6 +307,12 @@ export const getTheme = (mode: CatppuccinTheme) => {
const isLight = isLightTheme(mode); 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 = { const tokens: ThemeTokens = {
surface: { surface: {
base: palette.base.hex, base: palette.base.hex,
@@ -269,10 +324,21 @@ export const getTheme = (mode: CatppuccinTheme) => {
tint: alpha(palette.surface0.hex, isLight ? 0.6 : 0.42), tint: alpha(palette.surface0.hex, isLight ? 0.6 : 0.42),
tintStrong: alpha(palette.surface0.hex, isLight ? 0.78 : 0.6), tintStrong: alpha(palette.surface0.hex, isLight ? 0.78 : 0.6),
tintSubtle: alpha(palette.surface0.hex, isLight ? 0.42 : 0.28), 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), 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%)', 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: { accent: {
primary: palette.primary.hex, primary: palette.primary.hex,
@@ -297,8 +363,21 @@ export const getTheme = (mode: CatppuccinTheme) => {
sm: 10, sm: 10,
md: 14, md: 14,
lg: 20, lg: 20,
xl: 28,
pill: 999, 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 = { const themeOptions: ThemeOptions = {
@@ -329,7 +408,10 @@ export const getTheme = (mode: CatppuccinTheme) => {
borderRadius: tokens.radius.sm, borderRadius: tokens.radius.sm,
}, },
typography: { 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' }, h1: { fontWeight: 800, letterSpacing: '-0.02em' },
h2: { fontWeight: 800, letterSpacing: '-0.02em' }, h2: { fontWeight: 800, letterSpacing: '-0.02em' },
h3: { fontWeight: 700, letterSpacing: '-0.015em' }, h3: { fontWeight: 700, letterSpacing: '-0.015em' },
@@ -434,6 +516,7 @@ export const getTheme = (mode: CatppuccinTheme) => {
styleOverrides: { styleOverrides: {
paper: { paper: {
backgroundColor: tokens.surface.overlay, backgroundColor: tokens.surface.overlay,
backgroundImage: 'none',
backdropFilter: tokens.glass.blurStrong, backdropFilter: tokens.glass.blurStrong,
WebkitBackdropFilter: tokens.glass.blurStrong, WebkitBackdropFilter: tokens.glass.blurStrong,
border: '1px solid', 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, tokens,
}; };