({
p: 1.5,
- bgcolor: '#1e1e1e',
- color: '#d4d4d4',
- borderRadius: 2,
+ bgcolor: theme.tokens.terminal.bg,
+ color: theme.tokens.terminal.fg,
+ borderRadius: `${theme.tokens.radius.sm}px`,
maxHeight: 150,
overflowY: 'auto',
- border: '1px solid #333',
- }}
+ border: '1px solid',
+ borderColor: theme.tokens.terminal.border,
+ })}
>
{
p: 1.5,
borderRadius: 2,
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
- color: msg.is_admin ? 'white' : 'inherit',
+ color: msg.is_admin ? 'primary.contrastText' : 'inherit',
boxShadow: msg.is_admin ? 2 : 0,
}}
>
diff --git a/frontend/src/pages/categories/CategoryFlavors.tsx b/frontend/src/pages/categories/CategoryFlavors.tsx
index 119c29d..57b603e 100644
--- a/frontend/src/pages/categories/CategoryFlavors.tsx
+++ b/frontend/src/pages/categories/CategoryFlavors.tsx
@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors';
import {
PageShell,
- HeroBackdrop,
SectionHeader,
FlavorCard,
FlavorGrid,
@@ -32,7 +31,7 @@ const CategoryFlavors: React.FC = () => {
);
return (
- }>
+
diff --git a/frontend/src/pages/dashboard/DashboardHeader.tsx b/frontend/src/pages/dashboard/DashboardHeader.tsx
index f4c7dcb..0517690 100644
--- a/frontend/src/pages/dashboard/DashboardHeader.tsx
+++ b/frontend/src/pages/dashboard/DashboardHeader.tsx
@@ -145,7 +145,7 @@ const DashboardHeader: React.FC = ({
p: 0.5,
borderRadius: '50%',
background: `linear-gradient(135deg, ${grad[0]}, ${grad[1]})`,
- boxShadow: { xs: 'none', sm: '0 12px 48px rgba(0,0,0,0.25)' },
+ boxShadow: { xs: 'none', sm: theme.tokens.elevation.lg },
display: 'flex',
flexShrink: 0,
}}
diff --git a/frontend/src/pages/dashboard/MyReviewCard.tsx b/frontend/src/pages/dashboard/MyReviewCard.tsx
index a7bb41a..b3fb4a1 100644
--- a/frontend/src/pages/dashboard/MyReviewCard.tsx
+++ b/frontend/src/pages/dashboard/MyReviewCard.tsx
@@ -107,7 +107,7 @@ const MyReviewCard: React.FC = ({ rating, currentUsername })
};
return (
-
+
{isEditing ? (
diff --git a/frontend/src/pages/dashboard/RecommendationCard.tsx b/frontend/src/pages/dashboard/RecommendationCard.tsx
index 7a714f1..0e85293 100644
--- a/frontend/src/pages/dashboard/RecommendationCard.tsx
+++ b/frontend/src/pages/dashboard/RecommendationCard.tsx
@@ -1,4 +1,6 @@
import React from 'react';
+import { alpha } from '@mui/material/styles';
+import type { Theme } from '@mui/material/styles';
import { Box, CardContent, Chip, Tooltip, Typography } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import GroupsIcon from '@mui/icons-material/Groups';
@@ -31,6 +33,7 @@ const RecommendationCard: React.FC = ({ rec }) => {
return (
= ({ rec }) => {
sx={{
position: 'relative',
aspectRatio: '1/1',
- bgcolor: 'background.default',
+ bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.06),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx
index c9475c2..c678245 100644
--- a/frontend/src/pages/flavors/FlavorDetail.tsx
+++ b/frontend/src/pages/flavors/FlavorDetail.tsx
@@ -25,6 +25,7 @@ import {
} from '../../api/mutations/useRatingMutations';
import { useUpdateReply } from '../../api/mutations/useReplyMutations';
import { useTitle } from '../../hooks/useTitle';
+import { useBackdropArt } from '../../hooks/useBackdropArt';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import VerifiedIcon from '@mui/icons-material/Verified';
import CommentIcon from '@mui/icons-material/Comment';
@@ -36,7 +37,6 @@ import RatingBadge from '../../components/RatingBadge';
import StatusBadge from '../../components/StatusBadge';
import {
PageShell,
- HeroBackdrop,
GlassCard,
SectionHeader,
EmptyState,
@@ -86,6 +86,29 @@ const FlavorDetail: React.FC = () => {
const galleryImages = useMemo(() => flavor?.image_urls ?? [], [flavor?.image_urls]);
+ // The product shot itself becomes the page's backdrop — blown up and blurred
+ // far past recognition, so it reads as coloured light rather than a picture.
+ // It gives every pane on this page something of the actual product to refract.
+ const heroImage = galleryImages[0] ?? flavor?.image_url ?? null;
+ const backdropArt = useMemo(
+ () =>
+ heroImage ? (
+
+ ) : null,
+ [heroImage],
+ );
+ useBackdropArt(backdropArt, [backdropArt]);
+
const handleReplySubmit = async (ratingId: number) => {
const text = replyInputs[ratingId];
if (!text) return;
@@ -162,7 +185,7 @@ const FlavorDetail: React.FC = () => {
);
return (
- }>
+
diff --git a/frontend/src/pages/flavors/components/SimilarFlavorsSection.tsx b/frontend/src/pages/flavors/components/SimilarFlavorsSection.tsx
index 33f105f..c8039b9 100644
--- a/frontend/src/pages/flavors/components/SimilarFlavorsSection.tsx
+++ b/frontend/src/pages/flavors/components/SimilarFlavorsSection.tsx
@@ -1,4 +1,6 @@
import React from 'react';
+import { alpha } from '@mui/material/styles';
+import type { Theme } from '@mui/material/styles';
import { Box, CardContent, Chip, Skeleton, Stack, Tooltip, Typography } from '@mui/material';
import RecommendIcon from '@mui/icons-material/Recommend';
import GroupsIcon from '@mui/icons-material/Groups';
@@ -36,7 +38,7 @@ const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => {
sx={{
position: 'relative',
aspectRatio: '1/1',
- bgcolor: 'background.default',
+ bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.06),
borderBottom: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts
index a3aaba2..4c2270e 100644
--- a/frontend/src/theme.ts
+++ b/frontend/src/theme.ts
@@ -21,10 +21,27 @@ export interface ThemeTokens {
tint: string;
tintStrong: string;
tintSubtle: string;
+ /** Tint for the floating nav / command pane — sits over live content. */
+ tintChrome: string;
border: string;
+ borderStrong: string;
highlight: string;
blur: string;
blurStrong: string;
+ /** Cheaper blur for grid cards; a category page renders 40+ of these. */
+ blurCard: string;
+ /** Thickness in px of the refracting rim ring. */
+ rimWidth: number;
+ /** Very low-alpha sheen painted across the rim ring. */
+ rimSheen: string;
+ /** Filter stack applied to the rim ring only (see GlassDefs). */
+ rimFilter: string;
+ /** Chromatic variant — chrome tier only. */
+ rimFilterChroma: string;
+ /** Diagonal specular sheen laid over the pane body. */
+ specular: string;
+ /** Paired inset light/dark edges that sell pane thickness. */
+ shadowInner: string;
};
accent: {
primary: string;
@@ -43,8 +60,23 @@ export interface ThemeTokens {
sm: number;
md: number;
lg: number;
+ xl: number;
pill: number;
};
+ motion: {
+ fast: string;
+ base: string;
+ slow: string;
+ ease: string;
+ /** Slight overshoot — used for panes entering and nav collapse. */
+ spring: string;
+ };
+ /** Monospace log/terminal surfaces (admin job output). */
+ terminal: {
+ bg: string;
+ fg: string;
+ border: string;
+ };
}
export type CatppuccinTheme =
@@ -91,6 +123,23 @@ interface ThemePalette {
secondary: PaletteColor;
}
+/** sRGB relative luminance of a #rrggbb string, 0 (black) .. 1 (white). */
+const relativeLuminance = (hex: string): number => {
+ const h = hex.replace('#', '');
+ const full =
+ h.length === 3
+ ? h
+ .split('')
+ .map((c) => c + c)
+ .join('')
+ : h;
+ const channel = (i: number) => {
+ const v = parseInt(full.slice(i * 2, i * 2 + 2), 16) / 255;
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
+};
+
export const getTheme = (mode: CatppuccinTheme) => {
let palette: ThemePalette;
@@ -258,6 +307,12 @@ export const getTheme = (mode: CatppuccinTheme) => {
const isLight = isLightTheme(mode);
+ // How dark the page ground is. Near-black themes (t0p_blurryface, t0p_clancy)
+ // have nothing for a blur to gather, so the glass reads as a flat grey film
+ // unless we thin the blur and brighten the rim. `deep` is that signal.
+ const lum = relativeLuminance(palette.base.hex);
+ const deep = !isLight && lum < 0.06;
+
const tokens: ThemeTokens = {
surface: {
base: palette.base.hex,
@@ -269,10 +324,21 @@ export const getTheme = (mode: CatppuccinTheme) => {
tint: alpha(palette.surface0.hex, isLight ? 0.6 : 0.42),
tintStrong: alpha(palette.surface0.hex, isLight ? 0.78 : 0.6),
tintSubtle: alpha(palette.surface0.hex, isLight ? 0.42 : 0.28),
+ tintChrome: alpha(palette.base.hex, isLight ? 0.62 : deep ? 0.4 : 0.5),
border: alpha(palette.text.hex, isLight ? 0.08 : 0.12),
- highlight: alpha('#ffffff', isLight ? 0.65 : 0.08),
+ borderStrong: alpha(palette.text.hex, isLight ? 0.16 : deep ? 0.28 : 0.22),
+ highlight: alpha('#ffffff', isLight ? 0.65 : deep ? 0.16 : 0.08),
blur: 'blur(18px) saturate(160%)',
- blurStrong: 'blur(28px) saturate(180%)',
+ blurStrong: `blur(${deep ? 20 : 28}px) saturate(180%) brightness(${isLight ? 1.02 : 1.08})`,
+ blurCard: 'blur(10px) saturate(150%)',
+ rimWidth: deep ? 10 : 8,
+ rimSheen: alpha('#ffffff', isLight ? 0.22 : deep ? 0.14 : 0.07),
+ rimFilter: 'blur(2px) url(#hfr-rim)',
+ rimFilterChroma: 'blur(1.5px) url(#hfr-rim-chroma)',
+ // Light enters top-left, exits bottom-right. Keep the mid-band clear so
+ // the refracted backdrop stays readable through the pane.
+ specular: `linear-gradient(135deg, ${alpha('#ffffff', isLight ? 0.55 : deep ? 0.18 : 0.1)} 0%, ${alpha('#ffffff', 0)} 38%, ${alpha('#ffffff', 0)} 62%, ${alpha('#ffffff', isLight ? 0.18 : deep ? 0.07 : 0.04)} 100%)`,
+ shadowInner: `inset 0 1px 0 ${alpha('#ffffff', isLight ? 0.7 : deep ? 0.2 : 0.1)}, inset 0 -1px 0 ${alpha('#000000', isLight ? 0.06 : 0.35)}`,
},
accent: {
primary: palette.primary.hex,
@@ -297,8 +363,21 @@ export const getTheme = (mode: CatppuccinTheme) => {
sm: 10,
md: 14,
lg: 20,
+ xl: 28,
pill: 999,
},
+ motion: {
+ fast: '140ms',
+ base: '240ms',
+ slow: '420ms',
+ ease: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ spring: 'cubic-bezier(0.34, 1.36, 0.64, 1)',
+ },
+ terminal: {
+ bg: alpha(palette.crust.hex, isLight ? 0.9 : 0.72),
+ fg: isLight ? palette.text.hex : palette.subtext0.hex,
+ border: alpha(palette.text.hex, isLight ? 0.12 : 0.18),
+ },
};
const themeOptions: ThemeOptions = {
@@ -329,7 +408,10 @@ export const getTheme = (mode: CatppuccinTheme) => {
borderRadius: tokens.radius.sm,
},
typography: {
- fontFamily: '"Plus Jakarta Sans", "Inter", "Roboto", sans-serif',
+ // "…Variable" is the family name @fontsource-variable registers; the
+ // plain name is kept as a fallback for anyone who has it installed.
+ fontFamily:
+ '"Plus Jakarta Sans Variable", "Plus Jakarta Sans", "Inter", "Roboto", sans-serif',
h1: { fontWeight: 800, letterSpacing: '-0.02em' },
h2: { fontWeight: 800, letterSpacing: '-0.02em' },
h3: { fontWeight: 700, letterSpacing: '-0.015em' },
@@ -434,6 +516,7 @@ export const getTheme = (mode: CatppuccinTheme) => {
styleOverrides: {
paper: {
backgroundColor: tokens.surface.overlay,
+ backgroundImage: 'none',
backdropFilter: tokens.glass.blurStrong,
WebkitBackdropFilter: tokens.glass.blurStrong,
border: '1px solid',
@@ -442,6 +525,162 @@ export const getTheme = (mode: CatppuccinTheme) => {
},
},
},
+ // Everything below exists so the glass reaches dialogs, admin tables and
+ // toasts without per-page edits. Chip and Skeleton were the two largest
+ // untreated surface populations in the app.
+ MuiDialog: {
+ styleOverrides: {
+ paper: {
+ backgroundColor: tokens.surface.overlay,
+ backgroundImage: 'none',
+ backdropFilter: tokens.glass.blurStrong,
+ WebkitBackdropFilter: tokens.glass.blurStrong,
+ border: '1px solid',
+ borderColor: tokens.glass.border,
+ boxShadow: tokens.elevation.md,
+ },
+ },
+ },
+ MuiPopover: {
+ styleOverrides: {
+ paper: {
+ backgroundColor: tokens.surface.overlay,
+ backgroundImage: 'none',
+ backdropFilter: tokens.glass.blurStrong,
+ WebkitBackdropFilter: tokens.glass.blurStrong,
+ border: '1px solid',
+ borderColor: tokens.glass.border,
+ boxShadow: tokens.elevation.md,
+ },
+ },
+ },
+ MuiDrawer: {
+ styleOverrides: {
+ paper: {
+ backgroundColor: tokens.surface.overlay,
+ backgroundImage: 'none',
+ backdropFilter: tokens.glass.blurStrong,
+ WebkitBackdropFilter: tokens.glass.blurStrong,
+ border: '1px solid',
+ borderColor: tokens.glass.border,
+ boxShadow: tokens.elevation.md,
+ },
+ },
+ },
+ MuiBackdrop: {
+ styleOverrides: {
+ root: {
+ backgroundColor: alpha(palette.crust.hex, isLight ? 0.4 : 0.6),
+ backdropFilter: 'blur(6px)',
+ WebkitBackdropFilter: 'blur(6px)',
+ },
+ invisible: {
+ backgroundColor: 'transparent',
+ backdropFilter: 'none',
+ WebkitBackdropFilter: 'none',
+ },
+ },
+ },
+ MuiTooltip: {
+ styleOverrides: {
+ tooltip: {
+ backgroundColor: tokens.surface.overlay,
+ color: palette.text.hex,
+ backdropFilter: tokens.glass.blurStrong,
+ WebkitBackdropFilter: tokens.glass.blurStrong,
+ border: '1px solid',
+ borderColor: tokens.glass.border,
+ boxShadow: tokens.elevation.md,
+ borderRadius: tokens.radius.sm,
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ },
+ arrow: { color: tokens.surface.overlay },
+ },
+ },
+ MuiChip: {
+ styleOverrides: {
+ root: {
+ borderRadius: tokens.radius.pill,
+ fontWeight: 600,
+ border: '1px solid',
+ borderColor: tokens.glass.border,
+ },
+ filled: {
+ backgroundColor: tokens.glass.tint,
+ backdropFilter: tokens.glass.blurCard,
+ WebkitBackdropFilter: tokens.glass.blurCard,
+ },
+ outlined: {
+ backgroundColor: tokens.glass.tintSubtle,
+ backdropFilter: tokens.glass.blurCard,
+ WebkitBackdropFilter: tokens.glass.blurCard,
+ },
+ },
+ },
+ MuiSkeleton: {
+ styleOverrides: {
+ // Translucent so the backdrop still reads through a loading grid —
+ // an opaque grey block would punch holes in the aurora.
+ root: {
+ backgroundColor: alpha(palette.text.hex, isLight ? 0.07 : 0.09),
+ borderRadius: tokens.radius.sm,
+ },
+ },
+ },
+ MuiTableContainer: {
+ styleOverrides: {
+ root: { backgroundColor: 'transparent', backgroundImage: 'none' },
+ },
+ },
+ MuiTableHead: {
+ styleOverrides: {
+ root: {
+ backgroundColor: alpha(palette.surface0.hex, isLight ? 0.55 : 0.35),
+ backdropFilter: tokens.glass.blurCard,
+ WebkitBackdropFilter: tokens.glass.blurCard,
+ },
+ },
+ },
+ MuiTableCell: {
+ styleOverrides: {
+ root: { borderColor: tokens.glass.border },
+ },
+ },
+ MuiAccordion: {
+ styleOverrides: {
+ root: {
+ backgroundColor: tokens.glass.tintSubtle,
+ backgroundImage: 'none',
+ backdropFilter: tokens.glass.blurCard,
+ WebkitBackdropFilter: tokens.glass.blurCard,
+ border: '1px solid',
+ borderColor: tokens.glass.border,
+ borderRadius: tokens.radius.md,
+ boxShadow: 'none',
+ '&::before': { display: 'none' },
+ },
+ },
+ },
+ MuiAlert: {
+ styleOverrides: {
+ root: {
+ borderRadius: tokens.radius.md,
+ border: '1px solid',
+ borderColor: tokens.glass.border,
+ backdropFilter: tokens.glass.blur,
+ WebkitBackdropFilter: tokens.glass.blur,
+ },
+ },
+ },
+ MuiLinearProgress: {
+ styleOverrides: {
+ root: {
+ borderRadius: tokens.radius.pill,
+ backgroundColor: alpha(palette.text.hex, isLight ? 0.08 : 0.12),
+ },
+ },
+ },
},
tokens,
};