{username.split('').map((char, j) => (
{char}
@@ -71,6 +73,24 @@ const MatrixBanner: React.FC = ({ userna
);
};
+const pickBanner = (
+ banner: Banner | null,
+ props: DynamicBannerProps,
+): React.ReactElement => {
+ if (!banner || banner.slug === 'generative-web') {
+ return ;
+ }
+ if (banner.slug === 'matrix') return ;
+ if (banner.slug === 'nebula') return ;
+ if (banner.slug === 'fireflies') return ;
+ if (banner.slug === 'hextech') return ;
+ if (banner.slug === 'hextech-corruption')
+ return ;
+ if (banner.slug === 'generative-flow')
+ return ;
+ return ;
+};
+
const DynamicBanner: React.FC = (props) => {
const [banner, setBanner] = useState(null);
const [loading, setLoading] = useState(true);
@@ -94,37 +114,7 @@ const DynamicBanner: React.FC = (props) => {
if (loading) return null;
- // Default to GenerativeBanner if no active banner or unrecognized slug
- if (!banner || banner.slug === 'generative-web') {
- return ;
- }
-
- if (banner.slug === 'matrix') {
- return ;
- }
-
- if (banner.slug === 'nebula') {
- return ;
- }
-
- if (banner.slug === 'fireflies') {
- return ;
- }
-
- if (banner.slug === 'hextech') {
- return ;
- }
-
- if (banner.slug === 'hextech-corruption') {
- return ;
- }
-
- if (banner.slug === 'generative-flow') {
- return ;
- }
-
- // Fallback
- return ;
+ return {pickBanner(banner, props)};
};
export default DynamicBanner;
diff --git a/frontend/src/components/FirefliesBanner.tsx b/frontend/src/components/FirefliesBanner.tsx
index 9667217..24ff8a3 100644
--- a/frontend/src/components/FirefliesBanner.tsx
+++ b/frontend/src/components/FirefliesBanner.tsx
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
+import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface FirefliesBannerProps {
username: string;
@@ -19,6 +20,7 @@ interface FirefliesBannerProps {
const FirefliesBanner: React.FC = ({ username, palette, settings }) => {
const canvasRef = useRef(null);
const theme = useTheme();
+ const gate = useBannerFrameGate();
const count = settings?.count ?? 150;
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
@@ -133,7 +135,13 @@ const FirefliesBanner: React.FC = ({ username, palette, se
}
let time = 0;
- const draw = () => {
+ const draw = (now = 0) => {
+ const decision = gate(now);
+ if (decision === 'halt') return;
+ if (decision === 'skip') {
+ animationFrameId = requestAnimationFrame(draw);
+ return;
+ }
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
@@ -259,6 +267,7 @@ const FirefliesBanner: React.FC = ({ username, palette, se
glowSizeMultiplier,
flickerSpeed,
theme,
+ gate,
]);
return (
diff --git a/frontend/src/components/GenerativeBanner.tsx b/frontend/src/components/GenerativeBanner.tsx
index f3397f1..e939f0a 100644
--- a/frontend/src/components/GenerativeBanner.tsx
+++ b/frontend/src/components/GenerativeBanner.tsx
@@ -1,5 +1,6 @@
import React, { useRef, useEffect } from 'react';
import { alpha, useTheme } from '@mui/material';
+import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface GenerativeBannerProps {
username: string;
@@ -23,6 +24,7 @@ const GenerativeBanner: React.FC = ({
}) => {
const canvasRef = useRef(null);
const theme = useTheme();
+ const gate = useBannerFrameGate();
const nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140;
@@ -150,7 +152,13 @@ const GenerativeBanner: React.FC = ({
});
}
- const draw = () => {
+ const draw = (now = 0) => {
+ const decision = gate(now);
+ if (decision === 'halt') return;
+ if (decision === 'skip') {
+ animationFrameId = requestAnimationFrame(draw);
+ return;
+ }
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
@@ -294,6 +302,7 @@ const GenerativeBanner: React.FC = ({
connectionDist,
speed,
globalOpacity,
+ gate,
]);
return (
diff --git a/frontend/src/components/GenerativeFlowBanner.tsx b/frontend/src/components/GenerativeFlowBanner.tsx
index 523646a..affa2db 100644
--- a/frontend/src/components/GenerativeFlowBanner.tsx
+++ b/frontend/src/components/GenerativeFlowBanner.tsx
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
+import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface GenerativeFlowProps {
username: string;
@@ -18,6 +19,7 @@ interface GenerativeFlowProps {
const GenerativeFlowBanner: React.FC = ({ username, palette, settings }) => {
const canvasRef = useRef(null);
const theme = useTheme();
+ const gate = useBannerFrameGate();
const blobCount = settings?.blobCount ?? 8;
const flowSpeed = settings?.flowSpeed ?? 0.008;
@@ -135,7 +137,13 @@ const GenerativeFlowBanner: React.FC = ({ username, palette
});
}
- const draw = () => {
+ const draw = (now = 0) => {
+ const decision = gate(now);
+ if (decision === 'halt') return;
+ if (decision === 'skip') {
+ animationFrameId = requestAnimationFrame(draw);
+ return;
+ }
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
@@ -224,7 +232,7 @@ const GenerativeFlowBanner: React.FC = ({ username, palette
}
cancelAnimationFrame(animationFrameId);
};
- }, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
+ }, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme, gate]);
return (
}
+ />,
+ ),
+ );
+ expect(screen.getByTestId('icon')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..7f7c2f5
--- /dev/null
+++ b/frontend/src/components/ui/EmptyState.tsx
@@ -0,0 +1,50 @@
+import type { ReactNode } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import { GlassSurface } from './Glass';
+
+export interface EmptyStateProps {
+ title: ReactNode;
+ subtitle?: ReactNode;
+ icon?: ReactNode;
+ action?: ReactNode;
+ compact?: boolean;
+}
+
+export const EmptyState = ({ title, subtitle, icon, action, compact = false }: EmptyStateProps) => (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+ {action && {action}}
+
+);
diff --git a/frontend/src/components/ui/FlavorCard.test.tsx b/frontend/src/components/ui/FlavorCard.test.tsx
new file mode 100644
index 0000000..b58c47c
--- /dev/null
+++ b/frontend/src/components/ui/FlavorCard.test.tsx
@@ -0,0 +1,78 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { ThemeProvider } from '@mui/material/styles';
+import { getTheme } from '../../theme';
+import { FlavorCard } from './FlavorCard';
+
+const renderCard = (overrides = {}) =>
+ render(
+
+
+
+
+ ,
+ );
+
+describe('FlavorCard', () => {
+ it('renders name, category, image, and rating', () => {
+ renderCard();
+ expect(screen.getByText('Blueberry Dust')).toBeInTheDocument();
+ expect(screen.getByText('Fruit')).toBeInTheDocument();
+ const img = screen.getByAltText('Blueberry Dust') as HTMLImageElement;
+ expect(img.src).toContain('/media/flavors/blueberry.jpg');
+ expect(img.getAttribute('loading')).toBe('lazy');
+ });
+
+ it('links to /flavor/:id', () => {
+ renderCard();
+ const link = screen.getByRole('link');
+ expect(link.getAttribute('href')).toBe('/flavor/42');
+ });
+
+ it('omits category row when showCategory=false', () => {
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.queryByText('Fruit')).not.toBeInTheDocument();
+ });
+
+ it('renders status badge when flavor is legacy', () => {
+ renderCard({ is_legacy: true, is_available: true });
+ expect(screen.getByText(/Limited/i)).toBeInTheDocument();
+ });
+
+ it('renders caption when provided', () => {
+ renderCard({});
+ expect(screen.queryByText('12 reviews')).not.toBeInTheDocument();
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.getByText('12 reviews')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/ui/FlavorCard.tsx b/frontend/src/components/ui/FlavorCard.tsx
new file mode 100644
index 0000000..f4b58c8
--- /dev/null
+++ b/frontend/src/components/ui/FlavorCard.tsx
@@ -0,0 +1,112 @@
+import { Link } from 'react-router-dom';
+import Box from '@mui/material/Box';
+import CardContent from '@mui/material/CardContent';
+import Typography from '@mui/material/Typography';
+import { GlassCard } from './Glass';
+import RatingBadge from '../RatingBadge';
+import StatusBadge from '../StatusBadge';
+
+export interface FlavorCardData {
+ id: number;
+ name: string;
+ image_url?: string | null;
+ average_rating?: number;
+ is_legacy?: boolean;
+ is_available?: boolean;
+ category_name?: string;
+}
+
+export interface FlavorCardProps {
+ flavor: FlavorCardData;
+ caption?: string;
+ showCategory?: boolean;
+ compact?: boolean;
+}
+
+export const FlavorCard = ({
+ flavor,
+ caption,
+ showCategory = true,
+ compact = false,
+}: FlavorCardProps) => (
+
+
theme.tokens.elevation.md,
+ },
+ }}
+ >
+
+
+
+
+ {flavor.image_url && (
+
+
+
+ )}
+
+
+
+ {flavor.name}
+
+
+ {showCategory && flavor.category_name && (
+
+ {flavor.category_name}
+
+ )}
+
+
+
+ {caption && (
+
+ {caption}
+
+ )}
+
+
+
+
+);
diff --git a/frontend/src/components/ui/FormCard.test.tsx b/frontend/src/components/ui/FormCard.test.tsx
new file mode 100644
index 0000000..f40f3c7
--- /dev/null
+++ b/frontend/src/components/ui/FormCard.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ThemeProvider } from '@mui/material/styles';
+import Button from '@mui/material/Button';
+import TextField from '@mui/material/TextField';
+import { getTheme } from '../../theme';
+import { FormCard } from './FormCard';
+
+const wrap = (ui: React.ReactElement) => (
+
{ui}
+);
+
+describe('FormCard', () => {
+ it('renders title + subtitle + submits on form submit', () => {
+ const handleSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
+ render(
+ wrap(
+
Save}
+ >
+
+ ,
+ ),
+ );
+
+ expect(screen.getByText('Edit Profile')).toBeInTheDocument();
+ expect(screen.getByText('Update your display name')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+ expect(handleSubmit).toHaveBeenCalledOnce();
+ });
+
+ it('does not render a