mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
ui redesign
This commit is contained in:
1 parent
d0a027a2a1
commit
67da3061a6
49 files changed
+1496
-458
No files matched your search
@@ -8,6 +8,7 @@
|
|||||||
"@catppuccin/palette": "^1.7.1",
|
"@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
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
+32
-15
@@ -7,7 +7,10 @@ import CookieBanner from './components/CookieBanner';
|
|||||||
import { useAuth } from './hooks/useAuth';
|
import { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { alpha, keyframes, styled } from '@mui/material/styles';
|
||||||
|
import { BannerPerformanceWrapper } from '../banners';
|
||||||
|
import { GRAIN_DATA_URI } from './GlassDefs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page-wide layer everything else is seen *through*.
|
||||||
|
*
|
||||||
|
* Glass is only interesting with something going on behind it, so this sits
|
||||||
|
* fixed behind the whole app rather than per-page: a themed ground, slow
|
||||||
|
* drifting aurora blobs, an optional canvas art layer for hero routes, and a
|
||||||
|
* fine grain that both kills gradient banding and gives the rim refraction
|
||||||
|
* something to bend.
|
||||||
|
*
|
||||||
|
* Everything animated here moves `transform` only, so it stays on the
|
||||||
|
* compositor; nothing animates `filter` or `background-position`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const drift = (dx: string, dy: string, scale: number) => keyframes`
|
||||||
|
0% { transform: translate3d(0, 0, 0) scale(1); }
|
||||||
|
50% { transform: translate3d(${dx}, ${dy}, 0) scale(${scale}); }
|
||||||
|
100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Root = styled(Box)(({ theme }) => ({
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: -1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const Blob = styled(Box)({
|
||||||
|
position: 'absolute',
|
||||||
|
borderRadius: '50%',
|
||||||
|
filter: 'blur(70px)',
|
||||||
|
willChange: 'transform',
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface AppBackdropProps {
|
||||||
|
/**
|
||||||
|
* Optional canvas art for hero routes — one of the procedural banners.
|
||||||
|
* Mounted through `BannerPerformanceWrapper`, so it already pauses when
|
||||||
|
* off-screen and halts under `prefers-reduced-motion`.
|
||||||
|
*/
|
||||||
|
art?: ReactNode;
|
||||||
|
/** Pauses the aurora drift. Canvas art gates itself. */
|
||||||
|
reducedMotion?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppBackdrop = ({ art, reducedMotion = false }: AppBackdropProps) => (
|
||||||
|
<Root aria-hidden="true">
|
||||||
|
{/* Light themes need a much fainter wash: the same alpha that reads as a
|
||||||
|
moody glow on a dark ground turns a light page into solid colour. */}
|
||||||
|
<Blob
|
||||||
|
sx={(theme) => ({
|
||||||
|
top: '-18vh',
|
||||||
|
left: '-10vw',
|
||||||
|
width: '62vw',
|
||||||
|
height: '62vw',
|
||||||
|
background: `radial-gradient(circle at 50% 50%, ${alpha(theme.palette.primary.main, theme.palette.mode === 'light' ? 0.2 : 0.5)} 0%, ${alpha(theme.palette.primary.main, 0)} 68%)`,
|
||||||
|
animation: reducedMotion
|
||||||
|
? 'none'
|
||||||
|
: `${drift('12vw', '9vh', 1.15)} 58s ease-in-out infinite`,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Blob
|
||||||
|
sx={(theme) => ({
|
||||||
|
top: '10vh',
|
||||||
|
right: '-16vw',
|
||||||
|
width: '58vw',
|
||||||
|
height: '58vw',
|
||||||
|
background: `radial-gradient(circle at 50% 50%, ${alpha(theme.palette.secondary.main, theme.palette.mode === 'light' ? 0.16 : 0.42)} 0%, ${alpha(theme.palette.secondary.main, 0)} 70%)`,
|
||||||
|
animation: reducedMotion
|
||||||
|
? 'none'
|
||||||
|
: `${drift('-10vw', '14vh', 1.22)} 71s ease-in-out infinite`,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Blob
|
||||||
|
sx={(theme) => ({
|
||||||
|
bottom: '-24vh',
|
||||||
|
left: '22vw',
|
||||||
|
width: '70vw',
|
||||||
|
height: '70vw',
|
||||||
|
background: `radial-gradient(circle at 50% 50%, ${alpha(theme.palette.primary.main, theme.palette.mode === 'light' ? 0.12 : 0.3)} 0%, ${alpha(theme.palette.secondary.main, 0)} 72%)`,
|
||||||
|
animation: reducedMotion
|
||||||
|
? 'none'
|
||||||
|
: `${drift('8vw', '-11vh', 1.1)} 47s ease-in-out infinite`,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{art && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
opacity: 0.32,
|
||||||
|
// Fade the art out below the fold so long pages don't scroll into a
|
||||||
|
// busy background behind body copy.
|
||||||
|
maskImage: 'linear-gradient(180deg, #000 0%, #000 38%, transparent 78%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(180deg, #000 0%, #000 38%, transparent 78%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BannerPerformanceWrapper>{art}</BannerPerformanceWrapper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
opacity: 0.035,
|
||||||
|
backgroundImage: GRAIN_DATA_URI,
|
||||||
|
backgroundRepeat: 'repeat',
|
||||||
|
mixBlendMode: 'overlay',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { 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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* The SVG filter definitions behind the liquid-glass look, mounted exactly once
|
||||||
|
* (from `AppProviders`) so `url(#…)` resolves anywhere in the app.
|
||||||
|
*
|
||||||
|
* Why these are static: refraction reads as *liquid* because the backdrop moves
|
||||||
|
* behind a fixed displacement map — not because the map itself animates.
|
||||||
|
* Animating `baseFrequency` would re-rasterise the turbulence every frame; a
|
||||||
|
* static seed over the drifting aurora/canvas backdrop costs one raster pass
|
||||||
|
* and looks the same.
|
||||||
|
*
|
||||||
|
* These are consumed through `theme.tokens.glass.rimFilter` /
|
||||||
|
* `rimFilterChroma`, and only ever on a thin masked ring (see `Glass.tsx`) —
|
||||||
|
* never over a whole pane, which would be Chrome-only and far too expensive on
|
||||||
|
* a grid of cards.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Turbulence shared by both rim filters: soft, lens-like lobes rather than sand. */
|
||||||
|
const Turbulence = () => (
|
||||||
|
<>
|
||||||
|
<feTurbulence
|
||||||
|
type="fractalNoise"
|
||||||
|
baseFrequency="0.008 0.012"
|
||||||
|
numOctaves={2}
|
||||||
|
seed={7}
|
||||||
|
result="noise"
|
||||||
|
/>
|
||||||
|
<feGaussianBlur in="noise" stdDeviation="1.2" result="soft" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Isolates one channel of the source so it can be displaced independently. */
|
||||||
|
const channelMatrix = (channel: 'r' | 'g' | 'b') =>
|
||||||
|
({
|
||||||
|
r: '1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0',
|
||||||
|
g: '0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0',
|
||||||
|
b: '0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0',
|
||||||
|
})[channel];
|
||||||
|
|
||||||
|
export const GlassDefs = () => (
|
||||||
|
<svg
|
||||||
|
width="0"
|
||||||
|
height="0"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* Monochrome refraction — the workhorse, used on the `panel` tier. */}
|
||||||
|
<filter
|
||||||
|
id="hfr-rim"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
width="140%"
|
||||||
|
height="140%"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<Turbulence />
|
||||||
|
<feDisplacementMap
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="soft"
|
||||||
|
scale={11}
|
||||||
|
xChannelSelector="R"
|
||||||
|
yChannelSelector="G"
|
||||||
|
result="disp"
|
||||||
|
/>
|
||||||
|
{/* Displacement near an edge pulls in samples from outside the backdrop
|
||||||
|
image, which come back as transparent black and paint a dark fringe
|
||||||
|
along the rim. Compositing the displaced result *over* the original
|
||||||
|
backdrop fills those gaps with the undisplaced pixels instead. */}
|
||||||
|
<feComposite in="disp" in2="SourceGraphic" operator="over" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
{/* Chromatic refraction — R/G/B displaced by different amounts, then
|
||||||
|
screened back together, giving the prismatic edge real glass has.
|
||||||
|
Three displacement passes, so `chrome` tier only. */}
|
||||||
|
<filter
|
||||||
|
id="hfr-rim-chroma"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
width="140%"
|
||||||
|
height="140%"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<Turbulence />
|
||||||
|
<feColorMatrix
|
||||||
|
in="SourceGraphic"
|
||||||
|
type="matrix"
|
||||||
|
values={channelMatrix('r')}
|
||||||
|
result="rOnly"
|
||||||
|
/>
|
||||||
|
<feDisplacementMap
|
||||||
|
in="rOnly"
|
||||||
|
in2="soft"
|
||||||
|
scale={14}
|
||||||
|
xChannelSelector="R"
|
||||||
|
yChannelSelector="G"
|
||||||
|
result="rDisp"
|
||||||
|
/>
|
||||||
|
<feColorMatrix
|
||||||
|
in="SourceGraphic"
|
||||||
|
type="matrix"
|
||||||
|
values={channelMatrix('g')}
|
||||||
|
result="gOnly"
|
||||||
|
/>
|
||||||
|
<feDisplacementMap
|
||||||
|
in="gOnly"
|
||||||
|
in2="soft"
|
||||||
|
scale={11}
|
||||||
|
xChannelSelector="R"
|
||||||
|
yChannelSelector="G"
|
||||||
|
result="gDisp"
|
||||||
|
/>
|
||||||
|
<feColorMatrix
|
||||||
|
in="SourceGraphic"
|
||||||
|
type="matrix"
|
||||||
|
values={channelMatrix('b')}
|
||||||
|
result="bOnly"
|
||||||
|
/>
|
||||||
|
<feDisplacementMap
|
||||||
|
in="bOnly"
|
||||||
|
in2="soft"
|
||||||
|
scale={8}
|
||||||
|
xChannelSelector="R"
|
||||||
|
yChannelSelector="G"
|
||||||
|
result="bDisp"
|
||||||
|
/>
|
||||||
|
<feBlend in="rDisp" in2="gDisp" mode="screen" result="rg" />
|
||||||
|
<feBlend in="rg" in2="bDisp" mode="screen" result="chroma" />
|
||||||
|
{/* Same edge-fringe patch as #hfr-rim. */}
|
||||||
|
<feComposite in="chroma" in2="SourceGraphic" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fine grain, as a self-contained data URI rather than a `filter: url()` chain —
|
||||||
|
* it tiles as a plain background-image, so it costs nothing per frame.
|
||||||
|
* Layered at low opacity over the aurora it kills gradient banding and gives
|
||||||
|
* the rim refraction something fine-grained to bend.
|
||||||
|
*/
|
||||||
|
export const GRAIN_DATA_URI =
|
||||||
|
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\")";
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { styled, alpha } from '@mui/material/styles';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
|
|
||||||
export interface HeroBackdropProps {
|
|
||||||
variant?: 'mesh' | 'aurora' | 'minimal';
|
|
||||||
}
|
|
||||||
|
|
||||||
const Root = styled(Box, {
|
|
||||||
shouldForwardProp: (prop) => prop !== 'variant',
|
|
||||||
})<{ variant: NonNullable<HeroBackdropProps['variant']> }>(({ theme, variant }) => {
|
|
||||||
const { palette } = theme;
|
|
||||||
const p = palette.primary.main;
|
|
||||||
const s = palette.secondary.main;
|
|
||||||
|
|
||||||
const meshLayers = [
|
|
||||||
`radial-gradient(ellipse 60% 40% at 20% 10%, ${alpha(p, 0.35)} 0%, transparent 60%)`,
|
|
||||||
`radial-gradient(ellipse 50% 50% at 85% 25%, ${alpha(s, 0.28)} 0%, transparent 65%)`,
|
|
||||||
`radial-gradient(ellipse 70% 45% at 50% 95%, ${alpha(p, 0.18)} 0%, transparent 60%)`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const auroraLayers = [
|
|
||||||
`conic-gradient(from 210deg at 50% -10%, ${alpha(p, 0.35)}, ${alpha(s, 0.25)}, ${alpha(p, 0.1)}, ${alpha(p, 0.35)})`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const minimalLayers = [`linear-gradient(180deg, ${alpha(p, 0.12)} 0%, transparent 60%)`];
|
|
||||||
|
|
||||||
const backgroundImage =
|
|
||||||
variant === 'aurora'
|
|
||||||
? auroraLayers.join(', ')
|
|
||||||
: variant === 'minimal'
|
|
||||||
? minimalLayers.join(', ')
|
|
||||||
: meshLayers.join(', ');
|
|
||||||
|
|
||||||
return {
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage,
|
|
||||||
backgroundColor: palette.background.default,
|
|
||||||
filter: 'saturate(120%)',
|
|
||||||
// Soft mask so the backdrop fades into the page content
|
|
||||||
maskImage: 'linear-gradient(180deg, #000 0%, #000 55%, transparent 100%)',
|
|
||||||
WebkitMaskImage: 'linear-gradient(180deg, #000 0%, #000 55%, transparent 100%)',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const HeroBackdrop = ({ variant = 'mesh' }: HeroBackdropProps) => <Root variant={variant} />;
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import 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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { CSSObject, Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
/** How much the pane occludes what is behind it. */
|
||||||
|
type Intensity = 'subtle' | 'default' | 'strong';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What kind of glass this is — the axis that governs *cost*, not just looks.
|
||||||
|
*
|
||||||
|
* - `card` — grid cards and list rows. One cheap backdrop layer, CSS-only rim.
|
||||||
|
* A category page renders 40+ of these, so it never gets a second
|
||||||
|
* compositing layer.
|
||||||
|
* - `panel` — hero cards, section panels, forms. Refracting rim (monochrome).
|
||||||
|
* - `chrome` — nav, dialogs, menus, anything floating over live content.
|
||||||
|
* Refracting rim with chromatic split; the most expensive tier,
|
||||||
|
* reserved for the handful of panes that are always on screen.
|
||||||
|
*/
|
||||||
|
type Tier = 'card' | 'panel' | 'chrome';
|
||||||
|
|
||||||
|
export interface GlassOptions {
|
||||||
|
intensity?: Intensity;
|
||||||
|
tier?: Tier;
|
||||||
|
/** Adds the hover lift used by clickable cards and rows. */
|
||||||
|
interactive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tintFor = (theme: Theme, intensity: Intensity, tier: Tier) => {
|
||||||
|
const g = theme.tokens.glass;
|
||||||
|
if (tier === 'chrome') return g.tintChrome;
|
||||||
|
if (intensity === 'strong') return g.tintStrong;
|
||||||
|
if (intensity === 'subtle') return g.tintSubtle;
|
||||||
|
return g.tint;
|
||||||
|
};
|
||||||
|
|
||||||
|
const blurFor = (theme: Theme, intensity: Intensity, tier: Tier) => {
|
||||||
|
const g = theme.tokens.glass;
|
||||||
|
if (tier === 'card') return g.blurCard;
|
||||||
|
if (tier === 'chrome' || intensity === 'strong') return g.blurStrong;
|
||||||
|
return g.blur;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emitted with an explicit unit: a bare number for `borderRadius` is treated by
|
||||||
|
// MUI's `sx` system as a multiplier of `theme.shape.borderRadius`, so spreading
|
||||||
|
// this object into an `sx` prop would turn radius 20 into 200px.
|
||||||
|
const radiusFor = (theme: Theme, tier: Tier) =>
|
||||||
|
`${tier === 'card' ? theme.tokens.radius.md : theme.tokens.radius.lg}px`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full three-layer pane. Exported so the call sites that hand-roll
|
||||||
|
* `backdropFilter: theme.tokens.glass.blur` in an `sx` prop share one
|
||||||
|
* definition instead of drifting apart.
|
||||||
|
*
|
||||||
|
* Layer 1 (element) — tint + backdrop blur.
|
||||||
|
* Layer 2 (::before) — specular sheen and the paired inset light/dark edges
|
||||||
|
* that make the pane read as having thickness.
|
||||||
|
* Layer 3 (::after) — the rim ring. A transparent border plus a
|
||||||
|
* border-box/padding-box XOR mask confines it to a ring;
|
||||||
|
* on Chromium that ring additionally refracts the backdrop
|
||||||
|
* through `feTurbulence`+`feDisplacementMap`. Elsewhere it
|
||||||
|
* stays a light-catching gradient hairline, which is a
|
||||||
|
* designed fallback rather than a broken one.
|
||||||
|
*/
|
||||||
|
export const glassSx = (theme: Theme, options: GlassOptions = {}): CSSObject => {
|
||||||
|
const { intensity = 'default', tier = 'panel', interactive = false } = options;
|
||||||
|
const t = theme.tokens;
|
||||||
|
const blur = blurFor(theme, intensity, tier);
|
||||||
|
const rim = tier === 'chrome' ? t.glass.rimFilterChroma : t.glass.rimFilter;
|
||||||
|
const ringMask = 'linear-gradient(#000 0 0) border-box, linear-gradient(#000 0 0) padding-box';
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: 'relative',
|
||||||
|
backgroundColor: tintFor(theme, intensity, tier),
|
||||||
|
backgroundImage: 'none',
|
||||||
|
backdropFilter: blur,
|
||||||
|
WebkitBackdropFilter: blur,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: tier === 'chrome' ? t.glass.borderStrong : t.glass.border,
|
||||||
|
borderRadius: radiusFor(theme, tier),
|
||||||
|
boxShadow: tier === 'card' ? t.elevation.sm : t.elevation.md,
|
||||||
|
transition: `transform ${t.motion.base} ${t.motion.ease}, box-shadow ${t.motion.base} ${t.motion.ease}, border-color ${t.motion.base} ${t.motion.ease}, background-color ${t.motion.base} ${t.motion.ease}`,
|
||||||
|
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
background: t.glass.specular,
|
||||||
|
boxShadow: t.glass.shadowInner,
|
||||||
|
// Keep the sheen under the pane's own content.
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// The ring is deliberately almost colourless. Its job is to *bend* the
|
||||||
|
// backdrop, not to paint a bright band — a filled ring just reads as a
|
||||||
|
// thick white border. The crisp edge catch comes from `shadowInner` above.
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
border: `${tier === 'card' ? 1 : t.glass.rimWidth}px solid transparent`,
|
||||||
|
background: `linear-gradient(140deg, ${t.glass.rimSheen} 0%, transparent 40%, transparent 60%, ${t.glass.rimSheen} 100%)`,
|
||||||
|
mask: ringMask,
|
||||||
|
maskComposite: 'exclude',
|
||||||
|
WebkitMask: ringMask,
|
||||||
|
WebkitMaskComposite: 'xor',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Chromium only: the ring additionally bends the backdrop behind it.
|
||||||
|
...(tier === 'card'
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'[data-glass="refract"] &::after': {
|
||||||
|
backdropFilter: rim,
|
||||||
|
WebkitBackdropFilter: rim,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// prefers-reduced-transparency: drop translucency entirely rather than
|
||||||
|
// shipping a half-legible pane.
|
||||||
|
'[data-glass="flat"] &': {
|
||||||
|
backdropFilter: 'none',
|
||||||
|
WebkitBackdropFilter: 'none',
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
},
|
||||||
|
'[data-glass="flat"] &::before': { background: 'none' },
|
||||||
|
|
||||||
|
...(interactive
|
||||||
|
? {
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: t.elevation.lg,
|
||||||
|
borderColor: t.glass.borderStrong,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { Intensity as GlassIntensity, Tier as GlassTier };
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
export { GlassCard, GlassPaper, GlassSurface, type GlassIntensity } from './Glass';
|
export { GlassCard, GlassPaper, GlassSurface } from './Glass';
|
||||||
|
export { glassSx, type GlassIntensity, type GlassTier, type GlassOptions } from './glass-style';
|
||||||
|
export { GlassDefs, GRAIN_DATA_URI } from './GlassDefs';
|
||||||
|
export { AppBackdrop, type AppBackdropProps } from './AppBackdrop';
|
||||||
export { GlassAppBar, type GlassAppBarProps } from './GlassAppBar';
|
export { 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);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { BackdropArtContext } from '../contexts/backdrop-art-context';
|
||||||
|
|
||||||
|
/** Reads the currently published backdrop art. Used by `AppBackdrop` only. */
|
||||||
|
export const useBackdropArtValue = () => useContext(BackdropArtContext)?.art ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes canvas art into the global backdrop for as long as this component
|
||||||
|
* is mounted, then clears it. Pass a memoised element — an inline element
|
||||||
|
* recreated every render would re-publish (and so re-seed the canvas) on each
|
||||||
|
* one, hence the explicit `deps`.
|
||||||
|
*/
|
||||||
|
export const useBackdropArt = (art: ReactNode, deps: React.DependencyList = []) => {
|
||||||
|
const ctx = useContext(BackdropArtContext);
|
||||||
|
const setArt = ctx?.setArt;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setArt) return;
|
||||||
|
setArt(art);
|
||||||
|
return () => setArt(null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [setArt, ...deps]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { useGlassCapability } from './useGlassCapability';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
const mockMatchMedia = (matches: Record<string, boolean>) => {
|
||||||
|
const listeners = new Map<string, Set<Listener>>();
|
||||||
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: !!matches[query],
|
||||||
|
media: query,
|
||||||
|
addEventListener: (_: string, l: Listener) => {
|
||||||
|
if (!listeners.has(query)) listeners.set(query, new Set());
|
||||||
|
listeners.get(query)!.add(l);
|
||||||
|
},
|
||||||
|
removeEventListener: (_: string, l: Listener) => listeners.get(query)?.delete(l),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChromium = (isChromium: boolean) => {
|
||||||
|
Object.defineProperty(navigator, 'userAgentData', {
|
||||||
|
configurable: true,
|
||||||
|
value: isChromium ? { brands: [{ brand: 'Chromium' }, { brand: 'Not?A_Brand' }] } : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useGlassCapability', () => {
|
||||||
|
const realSupports = CSS.supports;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
CSS.supports = vi.fn().mockReturnValue(true);
|
||||||
|
mockMatchMedia({});
|
||||||
|
setChromium(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
CSS.supports = realSupports;
|
||||||
|
delete document.documentElement.dataset.glass;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves to the refract tier on a Chromium engine and stamps <html>', () => {
|
||||||
|
const { result } = renderHook(() => useGlassCapability());
|
||||||
|
expect(result.current.tier).toBe('refract');
|
||||||
|
expect(result.current.refract).toBe(true);
|
||||||
|
expect(document.documentElement.dataset.glass).toBe('refract');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to blur off-Chromium even though the value parses', () => {
|
||||||
|
// Firefox and Safari both accept `url()` in a filter list and then paint
|
||||||
|
// nothing, so parsing support alone must not promote a page to refract.
|
||||||
|
setChromium(false);
|
||||||
|
const { result } = renderHook(() => useGlassCapability());
|
||||||
|
expect(result.current.tier).toBe('blur');
|
||||||
|
expect(result.current.refract).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to blur when mask-composite is unavailable', () => {
|
||||||
|
CSS.supports = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((prop: string) => !prop.toLowerCase().includes('mask-composite'));
|
||||||
|
const { result } = renderHook(() => useGlassCapability());
|
||||||
|
expect(result.current.tier).toBe('blur');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops to the flat tier under prefers-reduced-transparency', () => {
|
||||||
|
mockMatchMedia({ '(prefers-reduced-transparency: reduce)': true });
|
||||||
|
const { result } = renderHook(() => useGlassCapability());
|
||||||
|
expect(result.current.tier).toBe('flat');
|
||||||
|
expect(document.documentElement.dataset.glass).toBe('flat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports reduced motion so the backdrop can halt its animations', () => {
|
||||||
|
mockMatchMedia({ '(prefers-reduced-motion: reduce)': true });
|
||||||
|
const { result } = renderHook(() => useGlassCapability());
|
||||||
|
expect(result.current.reducedMotion).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type GlassTier = 'refract' | 'blur' | 'flat';
|
||||||
|
|
||||||
|
export interface GlassCapability {
|
||||||
|
/** Which visual tier the document is currently rendering at. */
|
||||||
|
tier: GlassTier;
|
||||||
|
/** True when SVG rim refraction is live. */
|
||||||
|
refract: boolean;
|
||||||
|
/** True when the user asked for reduced motion — aurora + canvas must halt. */
|
||||||
|
reducedMotion: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mq = (query: string): MediaQueryList | null => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null;
|
||||||
|
try {
|
||||||
|
return window.matchMedia(query);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can this engine actually render `backdrop-filter: url(#svg-filter)`?
|
||||||
|
*
|
||||||
|
* `CSS.supports` alone is not enough: `url()` is syntactically valid in a
|
||||||
|
* filter-function-list, so Firefox and Safari both parse it happily and then
|
||||||
|
* paint nothing — a silent failure that would leave their rims invisible
|
||||||
|
* rather than gracefully flat. Chromium is currently the only engine that
|
||||||
|
* renders it, so we gate on a Chromium signal too. `userAgentData` is a real
|
||||||
|
* feature-detectable API rather than UA-string sniffing, and when it is absent
|
||||||
|
* we simply fall back a tier — which is a designed look, not a broken one.
|
||||||
|
*/
|
||||||
|
const supportsRefraction = (): boolean => {
|
||||||
|
if (typeof window === 'undefined' || typeof CSS === 'undefined' || !CSS.supports) return false;
|
||||||
|
const parses =
|
||||||
|
CSS.supports('backdrop-filter', 'url(#x)') ||
|
||||||
|
CSS.supports('-webkit-backdrop-filter', 'url(#x)');
|
||||||
|
if (!parses) return false;
|
||||||
|
// mask-composite carries the rim ring; without it there is no ring to refract.
|
||||||
|
const canMask =
|
||||||
|
CSS.supports('mask-composite', 'exclude') || CSS.supports('-webkit-mask-composite', 'xor');
|
||||||
|
if (!canMask) return false;
|
||||||
|
const brands = (navigator as Navigator & { userAgentData?: { brands?: { brand: string }[] } })
|
||||||
|
.userAgentData?.brands;
|
||||||
|
return Array.isArray(brands) && brands.some((b) => /Chromium/i.test(b.brand));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTier = (): GlassTier => {
|
||||||
|
if (mq('(prefers-reduced-transparency: reduce)')?.matches) return 'flat';
|
||||||
|
return supportsRefraction() ? 'refract' : 'blur';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the glass tier and stamps it on `<html data-glass>`, which is what
|
||||||
|
* every refraction rule in `Glass.tsx` keys off. Call once, high in the tree.
|
||||||
|
*/
|
||||||
|
export const useGlassCapability = (): GlassCapability => {
|
||||||
|
const [state, setState] = useState<GlassCapability>(() => ({
|
||||||
|
tier: 'blur',
|
||||||
|
refract: false,
|
||||||
|
reducedMotion: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const transparency = mq('(prefers-reduced-transparency: reduce)');
|
||||||
|
const motion = mq('(prefers-reduced-motion: reduce)');
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const tier = resolveTier();
|
||||||
|
const reducedMotion = !!motion?.matches;
|
||||||
|
document.documentElement.dataset.glass = tier;
|
||||||
|
setState((prev) =>
|
||||||
|
prev.tier === tier && prev.reducedMotion === reducedMotion
|
||||||
|
? prev
|
||||||
|
: { tier, refract: tier === 'refract', reducedMotion },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
transparency?.addEventListener?.('change', apply);
|
||||||
|
motion?.addEventListener?.('change', apply);
|
||||||
|
return () => {
|
||||||
|
transparency?.removeEventListener?.('change', apply);
|
||||||
|
motion?.removeEventListener?.('change', apply);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
:root {
|
: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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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,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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user